executors/parallels/parallels.go (378 lines of code) (raw):
package parallels
import (
"errors"
"fmt"
"runtime"
"time"
"gitlab.com/gitlab-org/gitlab-runner/common"
"gitlab.com/gitlab-org/gitlab-runner/common/buildlogger"
"gitlab.com/gitlab-org/gitlab-runner/executors"
"gitlab.com/gitlab-org/gitlab-runner/executors/vm"
prl "gitlab.com/gitlab-org/gitlab-runner/helpers/parallels"
"gitlab.com/gitlab-org/gitlab-runner/helpers/ssh"
)
type executor struct {
vm.Executor
vmName string
sshCommand ssh.Client
provisioned bool
ipAddress string
machineVerified bool
}
func (s *executor) isAppleSilicon() bool {
result := runtime.GOARCH == "arm64"
if result {
s.BuildLogger.Debugln("Apple Silicon detected")
}
return result
}
func (s *executor) waitForIPAddress(vmName string, seconds int) (string, error) {
var lastError error
if s.ipAddress != "" {
return s.ipAddress, nil
}
s.BuildLogger.Debugln("Requesting IP address...")
for i := 0; i < seconds; i++ {
var ipAddr string
var err error
if s.isAppleSilicon() {
ipAddr, err = prl.IPAddress(vmName)
} else {
mac, macError := prl.Mac(vmName)
if macError != nil {
return "", err
}
ipAddr, err = prl.IPAddressFromMac(mac)
}
if err == nil {
s.BuildLogger.Debugln("IP address found", ipAddr, "...")
s.ipAddress = ipAddr
return ipAddr, nil
}
lastError = err
time.Sleep(time.Second)
}
return "", lastError
}
func (s *executor) verifyMachine(vmName string) error {
if s.machineVerified {
return nil
}
ipAddr, err := s.waitForIPAddress(vmName, 120)
if err != nil {
return err
}
// Create SSH command
sshCommand := ssh.Client{
SshConfig: *s.Config.SSH,
ConnectRetries: 30,
}
sshCommand.Host = ipAddr
s.BuildLogger.Debugln("Connecting to SSH...")
err = sshCommand.Connect()
if err != nil {
return err
}
defer sshCommand.Cleanup()
err = sshCommand.Run(s.Context, ssh.Command{Command: "exit"})
if err != nil {
return err
}
s.machineVerified = true
return nil
}
func (s *executor) restoreFromSnapshot() error {
s.BuildLogger.Debugln("Requesting default snapshot for VM...")
snapshot, err := prl.GetDefaultSnapshot(s.vmName)
if err != nil {
return err
}
s.BuildLogger.Debugln("Reverting VM to snapshot", snapshot, "...")
err = prl.RevertToSnapshot(s.vmName, snapshot)
if err != nil {
return err
}
return nil
}
func (s *executor) createVM(baseImage string) error {
templateName := s.Config.Parallels.TemplateName
if templateName == "" {
templateName = baseImage + "-template"
}
// remove invalid template (removed?)
templateStatus, _ := prl.Status(templateName)
if templateStatus == prl.Invalid {
_ = prl.Unregister(templateName)
}
if !prl.Exist(templateName) {
s.BuildLogger.Debugln("Creating template from VM", baseImage, "...")
err := s.createClone(baseImage, templateName)
if err != nil {
return err
}
}
s.BuildLogger.Debugln("Creating runner from VM template...")
err := prl.CreateOsVM(s.vmName, templateName)
if err != nil {
return err
}
s.BuildLogger.Debugln("Bootstrapping VM...")
err = prl.Start(s.vmName)
if err != nil {
return err
}
// TODO: integration tests do fail on this due
// Unable to open new session in this virtual machine.
// Make sure the latest version of Parallels Tools is installed in this virtual machine and it has finished booting
s.BuildLogger.Debugln("Waiting for VM to start...")
err = prl.TryExec(s.vmName, 120, "exit", "0")
if err != nil {
return err
}
s.BuildLogger.Debugln("Waiting for VM to become responsive...")
err = s.verifyMachine(s.vmName)
if err != nil {
return err
}
return nil
}
func (s *executor) updateGuestTime() error {
s.BuildLogger.Debugln("Updating VM date...")
timeServer := s.Config.Parallels.TimeServer
if timeServer == "" {
timeServer = "time.apple.com"
}
// Try ntpdate first, this command is available in macOS versions prior to Mojave.
// This is not guaranteed, but there is high probability that ntpdate may be available on other UNIX-like systems.
_, err := prl.Exec(s.vmName, "which", "ntpdate")
if err == nil {
return prl.TryExec(s.vmName, 20, "sudo", "ntpdate", "-u", timeServer)
}
// Starting from Mojave, ntpdate is no longer available on macOS, sntp is supposed to be used instead.
_, err = prl.Exec(s.vmName, "which", "sntp")
if err == nil {
return prl.TryExec(s.vmName, 20, "sudo", "sntp", "-sS", timeServer)
}
// Neither sntp nor ntpdate is available, very likely guest OS is not macOS.
// Report a warning to a user and gracefully return.
//nolint:lll
s.BuildLogger.Warningln("Neither sntp nor ntpdate are available in a guest VM. Proceeding without time synchronization ...")
return nil
}
func (s *executor) Prepare(options common.ExecutorPrepareOptions) error {
err := s.AbstractExecutor.Prepare(options)
if err != nil {
return err
}
err = s.validateConfig()
if err != nil {
return err
}
err = s.printVersion()
if err != nil {
return err
}
var baseName string
baseName, err = s.Executor.GetBaseName(s.Config.Parallels.BaseName)
if err != nil {
return err
}
unregisterInvalidVM(s.vmName)
s.vmName = s.getVMName(baseName)
s.tryDeleteVM()
s.tryRestoreFromSnapshot()
if !prl.Exist(s.vmName) {
s.BuildLogger.Println("Creating new VM...")
err = s.createVM(baseName)
if err != nil {
return err
}
canCreateSnapshot := !s.Config.Parallels.DisableSnapshots && !s.isAppleSilicon()
if canCreateSnapshot {
s.BuildLogger.Println("Creating default snapshot...")
err = prl.CreateSnapshot(s.vmName, "Started")
if err != nil {
return err
}
}
}
err = s.ensureVMStarted()
if err != nil {
return err
}
return s.sshConnect()
}
func (s *executor) tryDeleteVM() {
shouldDelete := s.Config.Parallels.DisableSnapshots || s.isAppleSilicon()
if shouldDelete && prl.Exist(s.vmName) {
s.BuildLogger.Debugln("Deleting old VM...")
killAndUnregisterVM(s.vmName)
}
}
func (s *executor) printVersion() error {
version, err := prl.Version()
if err != nil {
return err
}
s.BuildLogger.Println("Using Parallels", version, "executor...")
return nil
}
func (s *executor) validateConfig() error {
if s.Config.Parallels.BaseName == "" {
return errors.New("missing BaseName setting from Parallels configuration")
}
if s.BuildShell.PassFile {
return errors.New("parallels doesn't support shells that require script file")
}
if s.Config.SSH == nil {
return errors.New("missing SSH configuration")
}
if s.Config.Parallels == nil {
return errors.New("missing Parallels configuration")
}
return s.ValidateAllowedImages(s.Config.Parallels.AllowedImages)
}
func (s *executor) tryRestoreFromSnapshot() {
// Apple Silicon does not support snapshots
if s.isAppleSilicon() {
return
}
if !prl.Exist(s.vmName) {
return
}
s.BuildLogger.Println("Restoring VM from snapshot...")
err := s.restoreFromSnapshot()
if err != nil {
s.BuildLogger.Println("Previous VM failed. Deleting, because", err)
killAndUnregisterVM(s.vmName)
}
}
func (s *executor) getVMName(baseName string) string {
if s.Config.Parallels.DisableSnapshots {
return baseName + "-" + s.Build.ProjectUniqueName()
}
return fmt.Sprintf(
"%s-runner-%s-concurrent-%d",
baseName,
s.Build.Runner.ShortDescription(),
s.Build.RunnerID,
)
}
func unregisterInvalidVM(vmName string) {
// remove invalid VM (removed?)
vmStatus, _ := prl.Status(vmName)
if vmStatus == prl.Invalid {
_ = prl.Unregister(vmName)
}
}
func killAndUnregisterVM(vmName string) {
_ = prl.Kill(vmName)
_ = prl.Delete(vmName)
_ = prl.Unregister(vmName)
}
func (s *executor) ensureVMStarted() error {
s.BuildLogger.Debugln("Checking VM status...")
status, err := prl.Status(s.vmName)
if err != nil {
return err
}
// Start VM if stopped
if status == prl.Stopped || status == prl.Suspended {
s.BuildLogger.Println("Starting VM...")
err = prl.Start(s.vmName)
if err != nil {
return err
}
}
if status != prl.Running {
s.BuildLogger.Debugln("Waiting for VM to run...")
err = prl.WaitForStatus(s.vmName, prl.Running, 60)
if err != nil {
return err
}
}
s.BuildLogger.Println("Waiting for VM to become responsive...")
err = s.verifyMachine(s.vmName)
if err != nil {
return err
}
s.provisioned = true
// TODO: integration tests do fail on this due
// Unable to open new session in this virtual machine.
// Make sure the latest version of Parallels Tools is installed in this virtual machine and it has finished booting
err = s.updateGuestTime()
if err != nil {
s.BuildLogger.Println("Could not sync with timeserver!")
return err
}
return nil
}
func (s *executor) sshConnect() error {
ipAddr, err := s.waitForIPAddress(s.vmName, 120)
if err != nil {
return err
}
s.BuildLogger.Debugln("Starting SSH command...")
s.sshCommand = ssh.Client{
SshConfig: *s.Config.SSH,
}
s.sshCommand.Host = ipAddr
s.BuildLogger.Debugln("Connecting to SSH server...")
return s.sshCommand.Connect()
}
func (s *executor) Run(cmd common.ExecutorCommand) error {
stdout := s.BuildLogger.Stream(buildlogger.StreamWorkLevel, buildlogger.Stdout)
defer stdout.Close()
stderr := s.BuildLogger.Stream(buildlogger.StreamWorkLevel, buildlogger.Stderr)
defer stderr.Close()
err := s.sshCommand.Run(cmd.Context, ssh.Command{
Command: s.BuildShell.CmdLine,
Stdin: cmd.Script,
Stdout: stdout,
Stderr: stderr,
})
if exitError, ok := err.(*ssh.ExitError); ok {
exitCode := exitError.ExitCode()
err = &common.BuildError{Inner: err, ExitCode: exitCode}
}
return err
}
func (s *executor) Cleanup() {
s.sshCommand.Cleanup()
if s.vmName != "" {
_ = prl.Kill(s.vmName)
if s.Config.Parallels.DisableSnapshots || !s.provisioned {
_ = prl.Delete(s.vmName)
}
}
s.AbstractExecutor.Cleanup()
}
func (s *executor) createClone(baseImage string, templateName string) error {
if s.isAppleSilicon() {
err := prl.CreateCloneTemplate(baseImage, templateName)
if err != nil {
return fmt.Errorf("%w (image: %q)", err, baseImage)
}
} else {
err := prl.CreateLinkedCloneTemplate(baseImage, templateName)
if err != nil {
return fmt.Errorf("%w (image: %q)", err, baseImage)
}
}
return nil
}
func init() {
options := executors.ExecutorOptions{
DefaultCustomBuildsDirEnabled: false,
DefaultSafeDirectoryCheckout: true,
DefaultBuildsDir: "builds",
DefaultCacheDir: "cache",
SharedBuildsDir: false,
Shell: common.ShellScriptInfo{
Shell: "bash",
Type: common.LoginShell,
RunnerCommand: "gitlab-runner",
},
ShowHostname: true,
}
creator := func() common.Executor {
return &executor{
Executor: vm.Executor{
AbstractExecutor: executors.AbstractExecutor{
ExecutorOptions: options,
},
},
}
}
featuresUpdater := func(features *common.FeaturesInfo) {
features.Variables = true
}
common.RegisterExecutorProvider("parallels", executors.DefaultExecutorProvider{
Creator: creator,
FeaturesUpdater: featuresUpdater,
DefaultShellName: options.Shell.Shell,
})
}