From a6783261f3dd76f5431a06a4b09037ca273f88c1 Mon Sep 17 00:00:00 2001 From: Patrik Cyvoct Date: Thu, 14 Jun 2018 11:07:22 +0200 Subject: [PATCH] Add Scaleway support for linuxkit command line tool Signed-off-by: Patrik Cyvoct --- src/cmd/linuxkit/push.go | 3 + src/cmd/linuxkit/push_scaleway.go | 104 ++++++ src/cmd/linuxkit/run.go | 3 + src/cmd/linuxkit/run_scaleway.go | 94 ++++++ src/cmd/linuxkit/scaleway.go | 523 ++++++++++++++++++++++++++++++ 5 files changed, 727 insertions(+) create mode 100644 src/cmd/linuxkit/push_scaleway.go create mode 100644 src/cmd/linuxkit/run_scaleway.go create mode 100644 src/cmd/linuxkit/scaleway.go diff --git a/src/cmd/linuxkit/push.go b/src/cmd/linuxkit/push.go index b4ef59257..866c0121d 100644 --- a/src/cmd/linuxkit/push.go +++ b/src/cmd/linuxkit/push.go @@ -19,6 +19,7 @@ func pushUsage() { fmt.Printf(" gcp\n") fmt.Printf(" openstack\n") fmt.Printf(" packet\n") + fmt.Printf(" scaleway\n") fmt.Printf(" vcenter\n") fmt.Printf("\n") fmt.Printf("'options' are the backend specific options.\n") @@ -44,6 +45,8 @@ func push(args []string) { pushOpenstack(args[1:]) case "packet": pushPacket(args[1:]) + case "scaleway": + pushScaleway(args[1:]) case "vcenter": pushVCenter(args[1:]) case "help", "-h", "-help", "--help": diff --git a/src/cmd/linuxkit/push_scaleway.go b/src/cmd/linuxkit/push_scaleway.go new file mode 100644 index 000000000..08af5e4f2 --- /dev/null +++ b/src/cmd/linuxkit/push_scaleway.go @@ -0,0 +1,104 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "strings" + + log "github.com/sirupsen/logrus" +) + +func pushScaleway(args []string) { + flags := flag.NewFlagSet("scaleway", flag.ExitOnError) + invoked := filepath.Base(os.Args[0]) + flags.Usage = func() { + fmt.Printf("USAGE: %s push scaleway [options] path\n\n", invoked) + fmt.Printf("'path' is the full path to an EFI ISO image. It will be copied to a new Scaleway instance in order to create a Scaeway image out of it.\n") + fmt.Printf("Options:\n\n") + flags.PrintDefaults() + } + nameFlag := flags.String("img-name", "", "Overrides the name used to identify the image name in Scaleway's images. Defaults to the base of 'path' with the '.iso' suffix removed") + tokenFlag := flags.String("token", "", "Token to connet to Scaleway API") + sshKeyFlag := flags.String("ssh-key", os.Getenv("HOME")+"/.ssh/id_rsa", "SSH key file") + instanceIDFlag := flags.String("instance-id", "", "Instance ID of a running Scaleway instance, with a second volume.") + deviceNameFlag := flags.String("device-name", "/dev/vdb", "Device name on which the image will be copied") + regionFlag := flags.String("region", defaultScalewayRegion, "Select scaleway region") + noCleanFlag := flags.Bool("no-clean", false, "Do not remove temporary instance and volumes") + + if err := flags.Parse(args); err != nil { + log.Fatal("Unable to parse args") + } + + remArgs := flags.Args() + if len(remArgs) == 0 { + fmt.Printf("Please specify the path to the image to push\n") + flags.Usage() + os.Exit(1) + } + path := remArgs[0] + + name := getStringValue(scalewayNameVar, *nameFlag, "") + token := getStringValue(tokenVar, *tokenFlag, "") + sshKeyFile := getStringValue(sshKeyVar, *sshKeyFlag, "") + instanceID := getStringValue(instanceIDVar, *instanceIDFlag, "") + deviceName := getStringValue(deviceNameVar, *deviceNameFlag, "") + region := getStringValue(regionVar, *regionFlag, defaultScalewayRegion) + + const suffix = ".iso" + if name == "" { + name = strings.TrimSuffix(path, suffix) + name = filepath.Base(name) + } + + client, err := NewScalewayClient(token, region) + if err != nil { + log.Fatalf("Unable to connect to Scaleway: %v", err) + } + + // if no instanceID is provided, we create the instance + if instanceID == "" { + instanceID, err = client.CreateInstance() + if err != nil { + log.Fatalf("Error creating a Scaleway instance: %v", err) + } + + err = client.BootInstanceAndWait(instanceID) + if err != nil { + log.Fatalf("Error booting instance: %v", err) + } + } + + volumeID, err := client.GetSecondVolumeID(instanceID) + if err != nil { + log.Fatalf("Error retrieving second volume ID: %v", err) + } + + err = client.CopyImageToInstance(instanceID, path, sshKeyFile) + if err != nil { + log.Fatalf("Error copying ISO file to Scaleway's instance: %v", err) + } + + err = client.WriteImageToVolume(instanceID, deviceName) + if err != nil { + log.Fatalf("Error writing ISO file to additional volume: %v", err) + } + + err = client.TerminateInstance(instanceID) + if err != nil { + log.Fatalf("Error terminating Scaleway's instance: %v", err) + } + + err = client.CreateScalewayImage(instanceID, volumeID, name) + if err != nil { + log.Fatalf("Error creating Scaleway image: %v", err) + } + + if !*noCleanFlag { + err = client.DeleteInstanceAndVolumes(instanceID) + if err != nil { + log.Fatalf("Error deleting Scaleway instance and volumes: %v") + } + } +} diff --git a/src/cmd/linuxkit/run.go b/src/cmd/linuxkit/run.go index 1d85d5c98..3cf3506ff 100644 --- a/src/cmd/linuxkit/run.go +++ b/src/cmd/linuxkit/run.go @@ -25,6 +25,7 @@ func runUsage() { fmt.Printf(" openstack\n") fmt.Printf(" packet\n") fmt.Printf(" qemu [linux]\n") + fmt.Printf(" scaleway\n") fmt.Printf(" vbox\n") fmt.Printf(" vcenter\n") fmt.Printf(" vmware\n") @@ -62,6 +63,8 @@ func run(args []string) { runPacket(args[1:]) case "qemu": runQemu(args[1:]) + case "scaleway": + runScaleway(args[1:]) case "vmware": runVMware(args[1:]) case "vbox": diff --git a/src/cmd/linuxkit/run_scaleway.go b/src/cmd/linuxkit/run_scaleway.go new file mode 100644 index 000000000..637a38c02 --- /dev/null +++ b/src/cmd/linuxkit/run_scaleway.go @@ -0,0 +1,94 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + + log "github.com/sirupsen/logrus" +) + +const ( + defaultScalewayInstanceType = "VC1S" + defaultScalewayRegion = "par1" + + scalewayNameVar = "SCW_IMAGE_NAME" // non-standard + tokenVar = "SCW_TOKEN" // non-standard + sshKeyVar = "SCW_SSH_KEY_FILE" // non-standard + instanceIDVar = "SCW_INSTANCE_ID" // non-standard + deviceNameVar = "SCW_DEVICE_NAME" // non-standard + regionVar = "SCW_TARGET_REGION" + + instanceTypeVar = "SCW_RUN_TYPE" // non-standard +) + +func runScaleway(args []string) { + flags := flag.NewFlagSet("scaleway", flag.ExitOnError) + invoked := filepath.Base(os.Args[0]) + flags.Usage = func() { + fmt.Printf("USAGE: %s run scaleway [options] [name]\n\n", invoked) + fmt.Printf("'name' is the name of a Scaleway image that has alread \n") + fmt.Printf("been uploaded using 'linuxkit push'\n\n") + fmt.Printf("Options:\n\n") + flags.PrintDefaults() + } + instanceTypeFlag := flags.String("instance-type", defaultScalewayInstanceType, "Scaleway instance type") + instanceNameFlag := flags.String("instance-name", "linuxkit", "Name of the create instance, default to the image name") + tokenFlag := flags.String("token", "", "Token to connect to Scaleway API") + regionFlag := flags.String("region", defaultScalewayRegion, "Select Scaleway region") + cleanFlag := flags.Bool("clean", false, "Remove instance") + noAttachFlag := flags.Bool("no-attach", false, "Don't attach to serial port, you will have to connect to instance manually") + + if err := flags.Parse(args); err != nil { + log.Fatal("Unable to parse args") + } + + remArgs := flags.Args() + if len(remArgs) == 0 { + fmt.Printf("Please specify the name of the image to boot\n") + flags.Usage() + os.Exit(1) + } + name := remArgs[0] + + instanceType := getStringValue(instanceTypeVar, *instanceTypeFlag, defaultScalewayInstanceType) + instanceName := getStringValue("", *instanceNameFlag, name) + token := getStringValue(tokenVar, *tokenFlag, "") + region := getStringValue(regionVar, *regionFlag, defaultScalewayRegion) + + client, err := NewScalewayClient(token, region) + if err != nil { + log.Fatalf("Unable to connect to Scaleway: %v", err) + } + + instanceID, err := client.CreateLinuxkitInstance(instanceName, name, instanceType) + if err != nil { + log.Fatalf("Unable to create Scaleway instance: %v", err) + } + + err = client.BootInstance(instanceID) + if err != nil { + log.Fatalf("Unable to boot Scaleway instance: %v", err) + } + + if !*noAttachFlag { + err = client.ConnectSerialPort(instanceID) + if err != nil { + log.Fatalf("Unable to connect to serial port: %v", err) + } + } + + if *cleanFlag { + err = client.TerminateInstance(instanceID) + if err != nil { + log.Fatalf("Unable to stop instance: %v", err) + } + + err = client.DeleteInstanceAndVolumes(instanceID) + if err != nil { + log.Fatalf("Unable to delete instance: %v", err) + } + } + +} diff --git a/src/cmd/linuxkit/scaleway.go b/src/cmd/linuxkit/scaleway.go new file mode 100644 index 000000000..aa0949ba4 --- /dev/null +++ b/src/cmd/linuxkit/scaleway.go @@ -0,0 +1,523 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net" + "os" + "path/filepath" + "strings" + "time" + + gotty "github.com/moul/gotty-client" + scw "github.com/scaleway/go-scaleway" + "github.com/scaleway/go-scaleway/logger" + "github.com/scaleway/go-scaleway/types" + log "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" +) + +// ScalewayClient contains state required for communication with Scaleway as well as the instance +type ScalewayClient struct { + api *scw.ScalewayAPI + fileName string + region string + sshConfig *ssh.ClientConfig +} + +// ScalewayConfig contains required field to read scaleway config file +type ScalewayConfig struct { + Organization string `json:"organization"` + Token string `json:"token"` + Version string `json:"version"` +} + +// NewScalewayClient creates a new scaleway client +func NewScalewayClient(token, region string) (*ScalewayClient, error) { + log.Debugf("Connecting to Scaleway") + organization := "" + if token == "" { + log.Debugf("Using .scwrc file to get token") + homeDir := os.Getenv("HOME") + if homeDir == "" { + homeDir = os.Getenv("USERPROFILE") // Windows support + } + if homeDir == "" { + return nil, fmt.Errorf("Home directory not found") + } + swrcPath := filepath.Join(homeDir, ".scwrc") + + file, err := ioutil.ReadFile(swrcPath) + if err != nil { + return nil, fmt.Errorf("Error reading Scaleway config file: %v", err) + } + + var scalewayConfig ScalewayConfig + err = json.Unmarshal(file, &scalewayConfig) + if err != nil { + return nil, fmt.Errorf("Error during unmarshal of Scaleway config file: %v", err) + } + + token = scalewayConfig.Token + organization = scalewayConfig.Organization + } + + api, err := scw.NewScalewayAPI(organization, token, "", region) + if err != nil { + return nil, err + } + + l := logger.NewDisableLogger() + api.Logger = l + + if organization == "" { + organisations, err := api.GetOrganization() + if err != nil { + return nil, err + } + api.Organization = organisations.Organizations[0].ID + } + + client := &ScalewayClient{ + api: api, + fileName: "", + region: region, + } + + return client, nil +} + +// CreateInstance create an instance with one additional volume +func (s *ScalewayClient) CreateInstance() (string, error) { + // get the Ubuntu Xenial image id + image, err := s.api.GetImageID("Ubuntu Xenial", "x86_64") // TODO fix arch and use from args + if err != nil { + return "", err + } + imageID := image.Identifier + + var serverDefinition types.ScalewayServerDefinition + + serverDefinition.Name = "linuxkit-builder" + serverDefinition.Image = &imageID + serverDefinition.CommercialType = "VC1M" // TODO use args? + + // creation of second volume + var volumeDefinition types.ScalewayVolumeDefinition + volumeDefinition.Name = "linuxkit-builder-volume" + volumeDefinition.Size = 50000000000 // FIX remove hardcoded value + volumeDefinition.Type = "l_ssd" + + log.Debugf("Creating volume on Scaleway") + volumeID, err := s.api.PostVolume(volumeDefinition) + if err != nil { + return "", err + } + + serverDefinition.Volumes = make(map[string]string) + serverDefinition.Volumes["1"] = volumeID + + serverID, err := s.api.PostServer(serverDefinition) + if err != nil { + return "", err + } + + log.Debugf("Created server %s on Scaleway", serverID) + return serverID, nil +} + +// GetSecondVolumeID returns the ID of the second volume of the server +func (s *ScalewayClient) GetSecondVolumeID(instanceID string) (string, error) { + server, err := s.api.GetServer(instanceID) + if err != nil { + return "", err + } + + secondVolume, ok := server.Volumes["1"] + if !ok { + return "", errors.New("No second volume found") + } + + return secondVolume.Identifier, nil +} + +// BootInstanceAndWait boots and wait for instance to be booted +func (s *ScalewayClient) BootInstanceAndWait(instanceID string) error { + err := s.api.PostServerAction(instanceID, "poweron") + if err != nil { + return err + } + + log.Debugf("Waiting for server %s to be started", instanceID) + + // code taken from scaleway-cli, could need some changes + promise := make(chan bool) + var server *types.ScalewayServer + var currentState string + + go func() { + defer close(promise) + + for { + server, err = s.api.GetServer(instanceID) + if err != nil { + promise <- false + return + } + + if currentState != server.State { + currentState = server.State + } + + if server.State == "running" { + break + } + if server.State == "stopped" { + promise <- false + return + } + time.Sleep(1 * time.Second) + } + + ip := server.PublicAddress.IP + if ip == "" && server.EnableIPV6 { + ip = fmt.Sprintf("[%s]", server.IPV6.Address) + } + dest := fmt.Sprintf("%s:22", ip) + for { + conn, err := net.Dial("tcp", dest) + if err == nil { + defer conn.Close() + break + } else { + time.Sleep(1 * time.Second) + } + } + promise <- true + }() + + loop := 0 + for { + select { + case done := <-promise: + if !done { + return err + } + log.Debugf("Server %s started", instanceID) + return nil + case <-time.After(time.Millisecond * 100): + loop = loop + 1 + if loop == 5 { + loop = 0 + } + } + } +} + +// getSSHAuth is uses to get the ssh.Signer needed to connect via SSH +func getSSHAuth(sshKeyPath string) (ssh.Signer, error) { + f, err := os.Open(sshKeyPath) + if err != nil { + return nil, err + } + defer f.Close() + + buf, err := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + signer, err := ssh.ParsePrivateKey(buf) + if err != nil { + return nil, err + } + return signer, err +} + +// CopyImageToInstance copies the image to the instance via ssh +func (s *ScalewayClient) CopyImageToInstance(instanceID, path, sshKeyPath string) error { + _, base := filepath.Split(path) + s.fileName = base + + server, err := s.api.GetServer(instanceID) + if err != nil { + return err + } + + signer, err := getSSHAuth(sshKeyPath) + if err != nil { + return err + } + + s.sshConfig = &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ + ssh.PublicKeys(signer), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // TODO validate server before? + } + + client, err := ssh.Dial("tcp", server.PublicAddress.IP+":22", s.sshConfig) // TODO remove hardocoded port? + if err != nil { + return err + } + + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + // code taken from bramvdbogaerde/go-scp + contentBytes, err := ioutil.ReadAll(f) + if err != nil { + return err + } + bytesReader := bytes.NewReader(contentBytes) + + log.Infof("Starting to upload %s on server", base) + + go func() { + w, err := session.StdinPipe() + if err != nil { + return + } + defer w.Close() + fmt.Fprintln(w, "C0600", int64(len(contentBytes)), base) + io.Copy(w, bytesReader) + fmt.Fprintln(w, "\x00") + }() + + session.Run("/usr/bin/scp -t /root/") // TODO remove hardcoded remote path? + return err +} + +// WriteImageToVolume does a dd command on the remote instance via ssh +func (s *ScalewayClient) WriteImageToVolume(instanceID, deviceName string) error { + server, err := s.api.GetServer(instanceID) + if err != nil { + return err + } + + client, err := ssh.Dial("tcp", server.PublicAddress.IP+":22", s.sshConfig) // TODO remove hardcoded port + use the same dial as before? + if err != nil { + return err + } + + session, err := client.NewSession() + if err != nil { + return err + } + defer session.Close() + + var ddPathBuf bytes.Buffer + session.Stdout = &ddPathBuf + + err = session.Run("which dd") // get the right path + if err != nil { + return err + } + + session, err = client.NewSession() + if err != nil { + return err + } + defer session.Close() + + ddCommand := strings.Trim(ddPathBuf.String(), " \n") + command := fmt.Sprintf("%s if=%s of=%s", ddCommand, s.fileName, deviceName) + + log.Infof("Starting writing iso to disk") + + err = session.Run(command) + if err != nil { + return err + } + + log.Infof("ISO image written to disk") + + return nil +} + +// TerminateInstance terminates the instance and wait for termination +func (s *ScalewayClient) TerminateInstance(instanceID string) error { + server, err := s.api.GetServer(instanceID) + if err != nil { + return err + } + + log.Debugf("Shutting down server %s", instanceID) + + err = s.api.PostServerAction(server.Identifier, "poweroff") + if err != nil { + return err + } + + // code taken from scaleway-cli + time.Sleep(10 * time.Second) + + var currentState string + + log.Debugf("Waiting for server to shutdown") + + for { + server, err = s.api.GetServer(instanceID) + if err != nil { + return err + } + if currentState != server.State { + currentState = server.State + } + if server.State == "stopped" { + break + } + time.Sleep(1 * time.Second) + } + return nil +} + +// CreateScalewayImage creates the image and delete old image and snapshot if same name +func (s *ScalewayClient) CreateScalewayImage(instanceID, volumeID, name string) error { + oldImage, err := s.api.GetImageID(name, "x86_64") + if err == nil { + err = s.api.DeleteImage(oldImage.Identifier) + if err != nil { + return err + } + } + + oldSnapshot, err := s.api.GetSnapshotID(name) + if err == nil { + err := s.api.DeleteSnapshot(oldSnapshot) + if err != nil { + return err + } + } + + snapshotID, err := s.api.PostSnapshot(volumeID, name) + if err != nil { + return err + } + + imageID, err := s.api.PostImage(snapshotID, name, "", "x86_64") // TODO remove hardcoded arch + if err != nil { + return err + } + + log.Infof("Image %s with ID %s created", name, imageID) + + return nil +} + +// DeleteInstanceAndVolumes deletes the instance and the volumes attached +func (s *ScalewayClient) DeleteInstanceAndVolumes(instanceID string) error { + server, err := s.api.GetServer(instanceID) + if err != nil { + return err + } + + err = s.api.DeleteServer(instanceID) + if err != nil { + return err + } + + for _, volume := range server.Volumes { + err = s.api.DeleteVolume(volume.Identifier) + if err != nil { + return err + } + } + + log.Infof("Server %s deleted", instanceID) + + return nil +} + +// CreateLinuxkitInstance creates an instance with the given linuxkit image +func (s *ScalewayClient) CreateLinuxkitInstance(instanceName, imageName, instanceType string) (string, error) { + // get the image ID + image, err := s.api.GetImageID(imageName, "x86_64") // TODO fix arch and use from args + if err != nil { + return "", err + } + imageID := image.Identifier + + var serverDefinition types.ScalewayServerDefinition + + serverDefinition.Name = instanceName + serverDefinition.Image = &imageID + serverDefinition.CommercialType = instanceType + serverDefinition.BootType = "local" + + log.Debugf("Creating volume on Scaleway") + + log.Debugf("Creating server %s on Scaleway", serverDefinition.Name) + serverID, err := s.api.PostServer(serverDefinition) + if err != nil { + return "", err + } + + return serverID, nil +} + +// BootInstance boots the specified instance, and don't wait +func (s *ScalewayClient) BootInstance(instanceID string) error { + err := s.api.PostServerAction(instanceID, "poweron") + if err != nil { + return err + } + return nil +} + +// ConnectSerialPort connects to the serial port of the instance +func (s *ScalewayClient) ConnectSerialPort(instanceID string) error { + var gottyURL string + switch s.region { + case "par1": + gottyURL = "https://tty-par1.scaleway.com/v2/" + case "ams1": + gottyURL = "https://tty-ams1.scaleway.com/" + default: + return errors.New("Instance have no region") + } + + fullURL := fmt.Sprintf("%s?arg=%s&arg=%s", gottyURL, s.api.Token, instanceID) + + log.Debugf("Connection to ", fullURL) + gottyClient, err := gotty.NewClient(fullURL) + if err != nil { + return err + } + + gottyClient.SkipTLSVerify = true + + gottyClient.UseProxyFromEnv = true + + err = gottyClient.Connect() + if err != nil { + return err + } + + done := make(chan bool) + + fmt.Println("You are connected, type 'Ctrl+q' to quit.") + go func() { + err = gottyClient.Loop() + if err != nil { + fmt.Printf("ERROR: " + err.Error()) + } + //gottyClient.Close() + done <- true + }() + <-done + return nil +}