package parallels import ( "errors" "fmt" "io/ioutil" "net" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "time" "github.com/docker/machine/libmachine/drivers" "github.com/docker/machine/libmachine/log" "github.com/docker/machine/libmachine/mcnflag" "github.com/docker/machine/libmachine/mcnutils" "github.com/docker/machine/libmachine/ssh" "github.com/docker/machine/libmachine/state" "github.com/hashicorp/go-version" ) const ( isoFilename = "boot2docker.iso" shareFolderName = "Users" shareFolderPath = "/Users" minDiskSize = 32 defaultCPU = 1 defaultMemory = 1024 defaultVideoSize = 64 defaultBoot2DockerURL = "" defaultNoShare = false defaultDiskSize = 20000 defaultSSHPort = 22 defaultSSHUser = "docker" ) var ( reMachineNotFound = regexp.MustCompile(`Failed to get VM config: The virtual machine could not be found..*`) reParallelsVersion = regexp.MustCompile(`.* (\d+\.\d+\.\d+).*`) reParallelsEdition = regexp.MustCompile(`edition="(.+)"`) errMachineExist = errors.New("machine already exists") errMachineNotExist = errors.New("machine does not exist") errSharedNotConnected = errors.New("Your Mac host is not connected to Shared network. Please, enable this option: 'Parallels Desktop' -> 'Preferences' -> 'Network' -> 'Shared' -> 'Connect Mac to this network'") v10, _ = version.NewVersion("10.0.0") v11, _ = version.NewVersion("11.0.0") ) // Driver for Parallels Desktop type Driver struct { *drivers.BaseDriver CPU int Memory int VideoSize int DiskSize int Boot2DockerURL string NoShare bool } // NewDriver creates a new Parallels Desktop driver with default settings func NewDriver(hostName, storePath string) drivers.Driver { return &Driver{ BaseDriver: &drivers.BaseDriver{ MachineName: hostName, StorePath: storePath, SSHUser: defaultSSHUser, SSHPort: defaultSSHPort, }, CPU: defaultCPU, Memory: defaultMemory, VideoSize: defaultVideoSize, DiskSize: defaultDiskSize, Boot2DockerURL: defaultBoot2DockerURL, NoShare: defaultNoShare, } } // Create a host using the driver's config func (d *Driver) Create() error { var ( err error ) b2dutils := mcnutils.NewB2dUtils(d.StorePath) if err = b2dutils.CopyIsoToMachineDir(d.Boot2DockerURL, d.MachineName); err != nil { return err } log.Infof("Creating SSH key...") sshKeyPath := d.GetSSHKeyPath() log.Debugf("SSH key: %s", sshKeyPath) if err = ssh.GenerateSSHKey(sshKeyPath); err != nil { return err } log.Infof("Creating Parallels Desktop VM...") ver, err := getParallelsVersion() if err != nil { return err } distribution := "boot2docker" if ver.LessThan(v11) { distribution = "linux-2.6" } absStorePath, _ := filepath.Abs(d.ResolveStorePath(".")) if err = prlctl("create", d.MachineName, "--distribution", distribution, "--dst", absStorePath, "--no-hdd"); err != nil { return err } cpus := d.CPU if cpus < 1 { cpus = int(runtime.NumCPU()) } if cpus > 32 { cpus = 32 } videoSize := d.VideoSize if videoSize < 2 { videoSize = defaultVideoSize } if err = prlctl("set", d.MachineName, "--select-boot-device", "off", "--cpus", fmt.Sprintf("%d", cpus), "--memsize", fmt.Sprintf("%d", d.Memory), "--videosize", fmt.Sprintf("%d", videoSize), "--cpu-hotplug", "off", "--on-window-close", "keep-running", "--longer-battery-life", "on", "--3d-accelerate", "off", "--device-bootorder", "cdrom0"); err != nil { return err } absISOPath, _ := filepath.Abs(d.ResolveStorePath(isoFilename)) if err = prlctl("set", d.MachineName, "--device-set", "cdrom0", "--iface", "sata", "--position", "0", "--image", absISOPath, "--connect"); err != nil { return err } initialDiskSize := minDiskSize // Fix for [GH-67]. Create a bigger disk on Parallels Desktop 13.0.* constraints, _ := version.NewConstraint(">= 13.0.0, < 13.1.0") if constraints.Check(ver) { initialDiskSize = 1891 } // Create a small plain disk. It will be converted and expanded later if err = prlctl("set", d.MachineName, "--device-add", "hdd", "--iface", "sata", "--position", "1", "--image", d.diskPath(), "--type", "plain", "--size", fmt.Sprintf("%d", initialDiskSize)); err != nil { return err } if err = d.generateDiskImage(d.DiskSize); err != nil { return err } // For Parallels Desktop >= 11.0.0 if ver.Compare(v11) >= 0 { // Enable headless mode if err = prlctl("set", d.MachineName, "--startup-view", "headless"); err != nil { return err } // Don't share any additional folders if err = prlctl("set", d.MachineName, "--shf-host-defined", "off"); err != nil { return err } // Enable time sync, don't touch timezone (this part is buggy) if err = prlctl("set", d.MachineName, "--time-sync", "on"); err != nil { return err } if err = prlctl("set", d.MachineName, "--disable-timezone-sync", "on"); err != nil { return err } } else { // Disable time sync feature because it has an issue with timezones. if err = prlctl("set", d.MachineName, "--time-sync", "off"); err != nil { return err } } // Configure Shared Folders if err = prlctl("set", d.MachineName, "--shf-host", "on", "--shared-cloud", "off", "--shared-profile", "off", "--smart-mount", "off"); err != nil { return err } if !d.NoShare { if err = prlctl("set", d.MachineName, "--shf-host-add", shareFolderName, "--path", shareFolderPath); err != nil { return err } } log.Infof("Starting Parallels Desktop VM...") // Don't use Start() since it expects to have a dhcp lease already if err = prlctl("start", d.MachineName); err != nil { return err } var ip string log.Infof("Waiting for VM to come online...") for i := 1; i <= 60; i++ { ip, err = d.getIPfromDHCPLease() if err != nil { log.Debugf("Not there yet %d/%d, error: %s", i, 60, err) time.Sleep(2 * time.Second) continue } if ip != "" { log.Debugf("Got an ip: %s", ip) conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", ip, d.SSHPort), time.Duration(2*time.Second)) if err != nil { log.Debugf("SSH Daemon not responding yet: %s", err) time.Sleep(2 * time.Second) continue } conn.Close() break } } if ip == "" { return fmt.Errorf("Machine didn't return an IP after 120 seconds, aborting") } d.IPAddress = ip if err := d.Start(); err != nil { return err } return nil } // DriverName returns the name of the driver as it is registered func (d *Driver) DriverName() string { return "parallels" } // GetIP returns an IP or hostname that this host is available at // e.g. 1.2.3.4 or docker-host-d60b70a14d3a.cloudapp.net func (d *Driver) GetIP() (string, error) { // Assume that Parallels Desktop hosts don't have IPs unless they are running s, err := d.GetState() if err != nil { return "", err } if s != state.Running { return "", drivers.ErrHostIsNotRunning } ip, err := d.getIPfromDHCPLease() if err != nil { return "", err } return ip, nil } // GetSSHHostname returns hostname for use with ssh func (d *Driver) GetSSHHostname() (string, error) { return d.GetIP() } // GetURL returns a Docker compatible host URL for connecting to this host // e.g. tcp://1.2.3.4:2376 func (d *Driver) GetURL() (string, error) { ip, err := d.GetIP() if err != nil { return "", err } if ip == "" { return "", nil } return fmt.Sprintf("tcp://%s:2376", ip), nil } // GetState returns the state that the host is in (running, stopped, etc) func (d *Driver) GetState() (state.State, error) { stdout, stderr, err := prlctlOutErr("list", d.MachineName, "--output", "status", "--no-header") if err != nil { if reMachineNotFound.FindString(stderr) != "" { return state.Error, errMachineNotExist } return state.Error, err } switch stdout { case "running\n": return state.Running, nil case "paused\n": return state.Paused, nil case "suspended\n": return state.Saved, nil case "stopping\n": return state.Stopping, nil case "stopped\n": return state.Stopped, nil } return state.None, nil } // Kill stops a host forcefully func (d *Driver) Kill() error { return prlctl("stop", d.MachineName, "--kill") } // PreCreateCheck allows for pre-create operations to make sure a driver is ready for creation func (d *Driver) PreCreateCheck() error { // Check platform type if runtime.GOOS != "darwin" { return fmt.Errorf("Driver \"parallels\" works only on OS X!") } // Check Parallels Desktop version ver, err := getParallelsVersion() if err != nil { return err } if ver.LessThan(v10) { return fmt.Errorf("Driver \"parallels\" supports only Parallels Desktop 10 and higher. You use: Parallels Desktop %s.", ver) } if ver.LessThan(v11) { log.Debugf("Found Parallels Desktop version: %s", ver) log.Infof("Driver \"parallels\" integration with Parallels Desktop 10 is maintained by open source community.") log.Infof("For Parallels supported configuration you should use it with Parallels Desktop 11 or later (Pro or Business edition).") return nil } // Check Parallels Desktop edition edit, err := getParallelsEdition() if err != nil { return err } log.Debugf("Found Parallels Desktop version: %d, edition: %s", ver, edit) switch edit { case "pro", "business": break default: return fmt.Errorf("Docker Machine can be used only with Parallels Desktop Pro or Business edition. You use: %s edition", edit) } // Check whether the host is connected to Shared network ok, err := isSharedConnected() if err != nil { return err } if !ok { return errSharedNotConnected } // Downloading boot2docker to cache should be done here to make sure // that a download failure will not leave a machine half created. b2dutils := mcnutils.NewB2dUtils(d.StorePath) if err := b2dutils.UpdateISOCache(d.Boot2DockerURL); err != nil { return err } return nil } // Remove a host func (d *Driver) Remove() error { s, err := d.GetState() if err != nil { if err == errMachineNotExist { log.Infof("machine does not exist, assuming it has been removed already") return nil } return err } if s == state.Running { if err := d.Kill(); err != nil { return err } } return prlctl("delete", d.MachineName) } // Restart a host. This may just call Stop(); Start() if the provider does not // have any special restart behaviour. func (d *Driver) Restart() error { if err := d.Stop(); err != nil { return err } return d.Start() } // GetCreateFlags registers the flags this driver adds to // "docker hosts create" func (d *Driver) GetCreateFlags() []mcnflag.Flag { return []mcnflag.Flag{ mcnflag.IntFlag{ EnvVar: "PARALLELS_MEMORY_SIZE", Name: "parallels-memory", Usage: "Size of memory for host in MB", Value: defaultMemory, }, mcnflag.IntFlag{ EnvVar: "PARALLELS_CPU_COUNT", Name: "parallels-cpu-count", Usage: "number of CPUs for the machine (-1 to use the number of CPUs available)", Value: defaultCPU, }, mcnflag.IntFlag{ EnvVar: "PARALLELS_VIDEO_SIZE", Name: "parallels-video-size", Usage: "Size of video for host in MB", Value: defaultVideoSize, }, mcnflag.IntFlag{ EnvVar: "PARALLELS_DISK_SIZE", Name: "parallels-disk-size", Usage: "Size of disk for host in MB", Value: defaultDiskSize, }, mcnflag.StringFlag{ EnvVar: "PARALLELS_BOOT2DOCKER_URL", Name: "parallels-boot2docker-url", Usage: "The URL of the boot2docker image. Defaults to the latest available version", Value: defaultBoot2DockerURL, }, mcnflag.BoolFlag{ Name: "parallels-no-share", Usage: "Disable the mount of your home directory", }, } } // SetConfigFromFlags configures the driver with the object that was returned // by RegisterCreateFlags func (d *Driver) SetConfigFromFlags(opts drivers.DriverOptions) error { d.CPU = opts.Int("parallels-cpu-count") d.Memory = opts.Int("parallels-memory") d.VideoSize = opts.Int("parallels-video-size") d.DiskSize = opts.Int("parallels-disk-size") d.Boot2DockerURL = opts.String("parallels-boot2docker-url") d.SetSwarmConfigFromFlags(opts) d.SSHUser = defaultSSHUser d.SSHPort = defaultSSHPort d.NoShare = opts.Bool("parallels-no-share") return nil } // Start a host func (d *Driver) Start() error { // Check whether the host is connected to Shared network ok, err := isSharedConnected() if err != nil { return err } if !ok { return errSharedNotConnected } s, err := d.GetState() if err != nil { return err } switch s { case state.Stopped, state.Saved, state.Paused: if err = prlctl("start", d.MachineName); err != nil { return err } log.Infof("Waiting for VM to start...") case state.Running: break default: log.Infof("VM not in restartable state") } if err = drivers.WaitForSSH(d); err != nil { return err } d.IPAddress, err = d.GetIP() if err != nil { return err } // Mount Share Folder if !d.NoShare { if err := d.mountShareFolder(shareFolderName, shareFolderPath); err != nil { return err } } return nil } // Stop a host gracefully func (d *Driver) Stop() error { if err := prlctl("stop", d.MachineName); err != nil { return err } for { s, err := d.GetState() if err != nil { return err } if s == state.Running { time.Sleep(1 * time.Second) } else { break } } return nil } func (d *Driver) getIPfromDHCPLease() (string, error) { DHCPLeaseFile := "/Library/Preferences/Parallels/parallels_dhcp_leases" stdout, _, err := prlctlOutErr("list", "-i", d.MachineName) macRe := regexp.MustCompile("net0.* mac=([0-9A-F]{12}) card=.*") macMatch := macRe.FindAllStringSubmatch(stdout, 1) if len(macMatch) != 1 { return "", fmt.Errorf("MAC address for NIC: nic0 on Virtual Machine: %s not found!\n", d.MachineName) } mac := macMatch[0][1] if len(mac) != 12 { return "", fmt.Errorf("Not a valid MAC address: %s. It should be exactly 12 digits.", mac) } leases, err := ioutil.ReadFile(DHCPLeaseFile) if err != nil { return "", err } ipRe := regexp.MustCompile("(.*)=\"(.*),(.*)," + strings.ToLower(mac) + ",.*\"") mostRecentIP := "" mostRecentLease := uint64(0) for _, l := range ipRe.FindAllStringSubmatch(string(leases), -1) { ip := l[1] expiry, _ := strconv.ParseUint(l[2], 10, 64) leaseTime, _ := strconv.ParseUint(l[3], 10, 32) log.Debugf("Found lease: %s for MAC: %s, expiring at %d, leased for %d s.\n", ip, mac, expiry, leaseTime) if mostRecentLease <= expiry-leaseTime { mostRecentIP = ip mostRecentLease = expiry - leaseTime } } if len(mostRecentIP) == 0 { return "", fmt.Errorf("IP lease not found for MAC address %s in: %s\n", mac, DHCPLeaseFile) } log.Debugf("Found IP lease: %s for MAC address %s\n", mostRecentIP, mac) return mostRecentIP, nil } func (d *Driver) diskPath() string { absDiskPath, _ := filepath.Abs(d.ResolveStorePath("disk.hdd")) return absDiskPath } func (d *Driver) mountShareFolder(shareName string, mountPoint string) error { // Check the host path is available if _, err := os.Stat(mountPoint); err != nil { if os.IsNotExist(err) { log.Infof("Host path '%s' does not exist. Skipping mount to VM...", mountPoint) return nil } return err } // Ensure that share is available on the guest side checkCmd := "sudo modprobe prl_fs && grep -w " + shareName + " /proc/fs/prl_fs/sf_list" if _, err := drivers.RunSSHCommandFromDriver(d, checkCmd); err != nil { log.Infof("Shared folder '%s' is unavailable. Skipping mount to VM...", shareName) return nil } // Mount shared folder mountCmd := "sudo mkdir -p " + mountPoint + " && sudo mount -t prl_fs " + shareName + " " + mountPoint if _, err := drivers.RunSSHCommandFromDriver(d, mountCmd); err != nil { return fmt.Errorf("Error mounting shared folder: %s", err) } return nil } // Make a boot2docker VM disk image. func (d *Driver) generateDiskImage(size int) error { tarBuf, err := mcnutils.MakeDiskImage(d.publicSSHKeyPath()) if err != nil { return err } minSizeBytes := int64(minDiskSize) << 20 // usually won't fit in 32-bit int (max 2GB) //Expand the initial image if needed if bufLen := int64(tarBuf.Len()); bufLen > minSizeBytes { bufLenMBytes := bufLen>>20 + 1 if err = prldisktool("resize", "--hdd", d.diskPath(), "--size", fmt.Sprintf("%d", bufLenMBytes)); err != nil { return err } } // Find hds file hdsList, err := filepath.Glob(d.diskPath() + "/*.hds") if err != nil { return err } if len(hdsList) == 0 { return fmt.Errorf("Could not find *.hds image in %s", d.diskPath()) } hdsPath := hdsList[0] log.Debugf("HDS image path: %s", hdsPath) // Write tar to the hds file hds, err := os.OpenFile(hdsPath, os.O_WRONLY, 0644) if err != nil { return err } defer hds.Close() hds.Seek(0, os.SEEK_SET) _, err = hds.Write(tarBuf.Bytes()) if err != nil { return err } hds.Close() // Convert image to expanding type and resize it if err := prldisktool("convert", "--expanding", "--merge", "--hdd", d.diskPath()); err != nil { return err } if err := prldisktool("resize", "--hdd", d.diskPath(), "--size", fmt.Sprintf("%d", size)); err != nil { return err } return nil } func (d *Driver) publicSSHKeyPath() string { return d.GetSSHKeyPath() + ".pub" } func detectCmdInPath(cmd string) string { if path, err := exec.LookPath(cmd); err == nil { return path } return cmd } // Detects Parallels Desktop major version func getParallelsVersion() (*version.Version, error) { stdout, _, err := prlctlOutErr("--version") if err != nil { return nil, err } // Parse Parallels Desktop version verRaw := reParallelsVersion.FindStringSubmatch(string(stdout)) if verRaw == nil { return nil, fmt.Errorf("Parallels Desktop version could not be fetched: %s", stdout) } ver, err := version.NewVersion(verRaw[1]) if err != nil { return nil, err } return ver, nil } // Detects Parallels Desktop edition func getParallelsEdition() (string, error) { stdout, _, err := prlsrvctlOutErr("info", "--license") if err != nil { return "", err } // Parse Parallels Desktop version res := reParallelsEdition.FindStringSubmatch(string(stdout)) if res == nil { return "", fmt.Errorf("Parallels Desktop edition could not be fetched!") } return res[1], nil } // Checks whether the host is connected to Shared network func isSharedConnected() (bool, error) { stdout, _, err := prlsrvctlOutErr("net", "info", "Shared") if err != nil { return false, err } reSharedIsConnected := regexp.MustCompile(`Bound To:.*`) return reSharedIsConnected.MatchString(stdout), nil }