github.com/distbuild/reclient@v0.0.0-20240401075343-3de72e395564/experiments/internal/pkg/vm/vm.go (about)

     1  // Copyright 2023 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package vm contains logic relevant to VMs used during an experiment.
    16  package vm
    17  
    18  import (
    19  	"context"
    20  	"fmt"
    21  	"os/exec"
    22  	"reflect"
    23  	"regexp"
    24  	"strings"
    25  	"time"
    26  
    27  	epb "github.com/bazelbuild/reclient/experiments/api/experiment"
    28  
    29  	log "github.com/golang/glog"
    30  )
    31  
    32  // VM is a struct holding information about an experiment VM.
    33  type VM struct {
    34  	name       string
    35  	diskName   string
    36  	project    string
    37  	vmSettings *epb.VMSettings
    38  	wsSettings *epb.WSSettings
    39  }
    40  
    41  // NewMachine prepares the machine for connection
    42  func NewMachine(name, project string, settings interface{}) *VM {
    43  	switch m := settings.(type) {
    44  	case *epb.RunConfiguration_VmSettings:
    45  		return &VM{
    46  			name:       sanitizeName(name),
    47  			project:    project,
    48  			vmSettings: m.VmSettings,
    49  			wsSettings: nil,
    50  		}
    51  	case *epb.RunConfiguration_WsSettings:
    52  		return &VM{
    53  			name:       m.WsSettings.GetAddress(),
    54  			project:    project,
    55  			vmSettings: nil,
    56  			wsSettings: m.WsSettings,
    57  		}
    58  	default:
    59  		log.Fatal("RunConfiguration is missing one of vm_settings or ws_settings")
    60  	}
    61  	return nil
    62  }
    63  
    64  func sanitizeName(name string) string {
    65  	var re = regexp.MustCompile(`[^a-zA-Z0-9\-]`)
    66  	return strings.ToLower(re.ReplaceAllString(name, "-"))
    67  }
    68  
    69  func (v *VM) gcePrefix() []string {
    70  	return []string{"gcloud", "compute", "--project=" + v.project}
    71  }
    72  
    73  func (v *VM) sshUser() string {
    74  	var sshUser string
    75  	if v.vmSettings != nil {
    76  		sshUser = v.vmSettings.GetSshUser()
    77  	} else if v.wsSettings != nil {
    78  		sshUser = v.wsSettings.GetSshUser()
    79  	}
    80  	if sshUser != "" {
    81  		sshUser += "@"
    82  	}
    83  	return sshUser
    84  }
    85  
    86  func (v *VM) sshKey() []string {
    87  	if v.vmSettings != nil {
    88  		if v.vmSettings.GetSshKeyPath() != "" {
    89  			return []string{"--ssh-key-file=" + v.vmSettings.GetSshKeyPath()}
    90  		}
    91  	} else if v.wsSettings != nil {
    92  		if v.wsSettings.GetSshKeyPath() != "" {
    93  			return []string{"-i" + v.wsSettings.GetSshKeyPath()}
    94  		}
    95  	}
    96  	return []string{}
    97  }
    98  
    99  func (v *VM) sshPrefix() []string {
   100  	if v.vmSettings != nil {
   101  		cmd := append(v.gcePrefix(), "ssh", "--ssh-flag=-tt", v.sshUser()+v.name, "--zone="+v.vmSettings.GetZone())
   102  		cmd = append(cmd, v.sshKey()...)
   103  		cmd = append(cmd, "--")
   104  		// May be overriden to specify an SSH proxy.
   105  		proxyCmd := ""
   106  		cmd = appendProxyArgs(cmd, proxyCmd)
   107  		return cmd
   108  	} else if v.wsSettings != nil {
   109  		cmd := []string{"ssh", v.sshUser() + v.name}
   110  		cmd = append(cmd, v.sshKey()...)
   111  		return cmd
   112  	}
   113  	return []string{}
   114  }
   115  
   116  // appendProxyArgs adds SSH proxy arguments to an SSH command if non-empty, returning the expanded command.
   117  // This is used internally at Google, and could be used in the future if this functionality is more broadly needed.
   118  // Assumes that `cmd` has the complete ssh command up to the point where proxy arguments should be added.
   119  func appendProxyArgs(cmd []string, proxyCmd string) []string {
   120  	if len(proxyCmd) > 0 {
   121  		cmd = append(cmd, "-o", fmt.Sprintf("ProxyCommand=%s", proxyCmd))
   122  	}
   123  	return cmd
   124  }
   125  
   126  func (v *VM) scpPrefix() []string {
   127  	if v.vmSettings != nil {
   128  		cmd := append(v.gcePrefix(), "scp", "--zone="+v.vmSettings.GetZone())
   129  		cmd = append(cmd, v.sshKey()...)
   130  		return append(cmd, "--")
   131  	} else if v.wsSettings != nil {
   132  		cmd := []string{"scp"}
   133  		cmd = append(cmd, v.sshKey()...)
   134  		return cmd
   135  	}
   136  	return []string{}
   137  }
   138  
   139  // Name returns the name of the VM.
   140  func (v *VM) Name() string {
   141  	return v.name
   142  }
   143  
   144  // CreateWithDisk creates the VM and attaches an image disk to it.
   145  func (v *VM) CreateWithDisk(ctx context.Context) error {
   146  	if v.vmSettings == nil {
   147  		log.Infof("Not a VM; skipped creating workstation %v", v.name)
   148  		return nil
   149  	}
   150  
   151  	log.Infof("Creating VM %v", v.name)
   152  	if v.vmSettings.GetImage() == "" && v.vmSettings.GetImageProject() == "" {
   153  		return fmt.Errorf("no boot image configuration found")
   154  	}
   155  	if v.vmSettings.GetDiskImage() == "" && v.vmSettings.GetDiskImageProject() == "" {
   156  		return fmt.Errorf("no disk image configuration found")
   157  	}
   158  
   159  	args := append(v.gcePrefix(),
   160  		"instances", "create", v.name,
   161  		"--zone="+v.vmSettings.GetZone(), "--image="+v.vmSettings.GetImage(),
   162  		"--scopes=https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/userinfo.email",
   163  		"--image-project="+v.vmSettings.GetImageProject(),
   164  		"--machine-type="+v.vmSettings.GetMachineType())
   165  
   166  	v.diskName = v.name + "-source-disk"
   167  	diskType := "pd-ssd"
   168  	if v.vmSettings.GetDiskType() != "" {
   169  		diskType = v.vmSettings.GetDiskType()
   170  	}
   171  	createDiskArg := fmt.Sprintf("--create-disk=name=%s,image=%s,image-project=%s,type=%s",
   172  		v.diskName, v.vmSettings.GetDiskImage(), v.vmSettings.GetDiskImageProject(), diskType)
   173  
   174  	args = append(args, createDiskArg)
   175  	args = append(args, v.vmSettings.GetCreationFlags()...)
   176  	log.Infof("Args: %v", args)
   177  	if oe, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
   178  		return fmt.Errorf("failed to create VM: %v, outerr: %v", err, string(oe))
   179  	}
   180  	log.Infof("Created VM %v", v.name)
   181  
   182  	// Choosing an SSH user is incompatible with OS login.
   183  	// Also notice that GCE Windows VM do not support OS login.
   184  	if v.vmSettings.GetSshUser() != "" {
   185  		args = append(v.gcePrefix(), "instances", "add-metadata", v.name,
   186  			"--zone="+v.vmSettings.GetZone(),
   187  			"--metadata", "enable-oslogin=false")
   188  		if oe, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
   189  			return fmt.Errorf("failed to change VM metadata: %v, outerr: %v", err, string(oe))
   190  		}
   191  
   192  		log.Infof("Disabled OS login on %v", v.name)
   193  	}
   194  
   195  	// resizes system disk to a size specified in VM settings
   196  	if v.vmSettings.GetSystemDiskSize() != "" {
   197  		args = append(v.gcePrefix(), "disks", "resize", v.name,
   198  			"--size="+v.vmSettings.GetSystemDiskSize(),
   199  			"--zone="+v.vmSettings.GetZone(),
   200  			"--quiet")
   201  		if oe, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
   202  			return fmt.Errorf("failed to resize system disk size of VM: %v, outerr: %v", err, string(oe))
   203  		}
   204  		log.Infof("Resized system disk to %v", v.vmSettings.GetSystemDiskSize())
   205  	}
   206  	return nil
   207  }
   208  
   209  // Mount mounts the attached disk to the VM.
   210  func (v *VM) Mount(ctx context.Context) error {
   211  	if v.vmSettings == nil {
   212  		log.Infof("Not a VM; skipped mounting disks to workstation %v", v.name)
   213  		return nil
   214  	}
   215  
   216  	log.Infof("Mounting disk %v", v.name)
   217  	if v.vmSettings.GetDiskImage() == "" && v.vmSettings.GetDiskImageProject() == "" {
   218  		return fmt.Errorf("no disk configuration found")
   219  	}
   220  	if err := v.waitUntilUp(ctx); err != nil {
   221  		return err
   222  	}
   223  
   224  	var err error
   225  	if v.vmSettings.GetImageOs() == epb.VMSettings_WINDOWS {
   226  		err = v.mountWin(ctx)
   227  	} else {
   228  		err = v.mountLinux(ctx)
   229  	}
   230  
   231  	if err != nil {
   232  		return err
   233  	}
   234  
   235  	log.Infof("Mounted disk %v", v.name)
   236  	return nil
   237  }
   238  
   239  func (v *VM) mountLinux(ctx context.Context) error {
   240  	mountPoint := "/img"
   241  	if _, err := v.RunCommand(ctx, &epb.Command{
   242  		Args: []string{
   243  			"sudo", "mkdir", "-p", mountPoint,
   244  		},
   245  	}); err != nil {
   246  		return fmt.Errorf("failed to mkdir mount point: %v", err)
   247  	}
   248  	if _, err := v.RunCommand(ctx, &epb.Command{
   249  		Args: []string{
   250  			"sudo", "mount", "-o", "discard,defaults", "/dev/sdb", mountPoint,
   251  		},
   252  	}); err != nil {
   253  		return fmt.Errorf("failed to mount disk: %v", err)
   254  	}
   255  	return nil
   256  }
   257  
   258  // Mount mounts the attached disk to the Windows VM.
   259  func (v *VM) mountWin(ctx context.Context) error {
   260  	// From some testing, Windows seems to already mount the image correctly
   261  	// at D:\ as long as it's been previously formatted.
   262  	return nil
   263  }
   264  
   265  // ImageDisk takes an image from the attached disk.
   266  func (v *VM) ImageDisk(ctx context.Context) error {
   267  	if v.vmSettings == nil {
   268  		log.Infof("Not a VM; skipped imaging disks from workstation %v", v.name)
   269  		return nil
   270  	}
   271  
   272  	log.Infof("Imaging disk %v", v.name)
   273  	if v.vmSettings.GetDiskImage() == "" && v.vmSettings.GetDiskImageProject() == "" {
   274  		return fmt.Errorf("no disk configuration found")
   275  	}
   276  	name := v.name + "-source-disk"
   277  	args := append(v.gcePrefix(),
   278  		"images", "create", fmt.Sprintf("%v-%v", v.name, v.vmSettings.GetDiskImage()),
   279  		"--zone="+v.vmSettings.GetZone(),
   280  		"--source-disk="+v.diskName,
   281  		"--source-disk-zone="+v.vmSettings.GetZone())
   282  	if oe, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
   283  		return fmt.Errorf("failed to create disk %v: %v, outerr: %v", name, err, string(oe))
   284  	}
   285  	log.Infof("Imaged disk %v", v.name)
   286  	return nil
   287  }
   288  
   289  // CopyFilesToVM copies files from the local machine to the VM.
   290  func (v *VM) CopyFilesToVM(ctx context.Context, src, dest string) error {
   291  	log.Infof("Copying files %v", src)
   292  	args := append(v.scpPrefix(),
   293  		src, fmt.Sprintf("%v%v:%v", v.sshUser(), v.name, dest))
   294  	log.Infof("Args: %v", args)
   295  	// Use bash here to expand * when copying multiple files.
   296  	if oe, err := exec.CommandContext(ctx, "/bin/bash", "-c", strings.Join(args, " ")).CombinedOutput(); err != nil {
   297  		return fmt.Errorf("failed to copy files to VM: %v, outerr: %v", err, string(oe))
   298  	}
   299  	log.Infof("Copied files %v", src)
   300  	return nil
   301  }
   302  
   303  // CopyFilesFromVM copies files from the VM to the local machine.
   304  func (v *VM) CopyFilesFromVM(ctx context.Context, src, dest string) error {
   305  	log.Infof("Copying files %v", src)
   306  	args := append(v.scpPrefix(),
   307  		fmt.Sprintf("%v%v:%v", v.sshUser(), v.name, src), dest)
   308  	log.Infof("Args: %v", args)
   309  	// Use bash here to expand * when copying multiple files.
   310  	if oe, err := exec.CommandContext(ctx, "/bin/bash", "-c", strings.Join(args, " ")).CombinedOutput(); err != nil {
   311  		return fmt.Errorf("failed to copy files from VM: %v, outerr: %v", err, string(oe))
   312  	}
   313  	log.Infof("Copied files %v", src)
   314  	return nil
   315  }
   316  
   317  func (v *VM) waitUntilUp(ctx context.Context) error {
   318  	for _, i := range []int{10, 30, 60, 120} {
   319  		time.Sleep(time.Duration(i) * time.Second)
   320  		if _, err := v.RunCommand(ctx, &epb.Command{
   321  			Args: []string{
   322  				"echo", "Hello",
   323  			},
   324  		}); err == nil {
   325  			return nil
   326  		}
   327  	}
   328  	return fmt.Errorf("VM %v did not startup in time", v.name)
   329  }
   330  
   331  // Delete deletes the VM image. If dryRun, it just returns the command that will
   332  // delete the VM.
   333  func (v *VM) Delete(ctx context.Context, dryRun bool) (string, error) {
   334  	if v.vmSettings == nil {
   335  		log.Infof("Not a VM; skipped deleting workstation %v", v.name)
   336  		return "", nil
   337  	}
   338  
   339  	log.Infof("Deleting VM %v (DryRun: %v)", v.name, dryRun)
   340  	args := append(v.gcePrefix(), "instances", "delete", v.name,
   341  		"--zone="+v.vmSettings.GetZone())
   342  	if !dryRun {
   343  		if oe, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput(); err != nil {
   344  			return "", fmt.Errorf("failed to delete VM: %v, outerr: %v", err, string(oe))
   345  		}
   346  	}
   347  	log.Infof("Deleted VM %v (DryRun: %v)", v.name, dryRun)
   348  	return strings.Join(args, " "), nil
   349  }
   350  
   351  // RunCommand runs a command on the VM.
   352  func (v *VM) RunCommand(ctx context.Context, cmd *epb.Command) (string, error) {
   353  	log.V(3).Infof("Running command %+v on VM %v", cmd, v.name)
   354  	args := append(v.sshPrefix(), strings.Join(cmd.Args, " "))
   355  	oe, err := exec.CommandContext(ctx, args[0], args[1:]...).CombinedOutput()
   356  	if err != nil {
   357  		return "", fmt.Errorf("failed to run command on %v: %v, outerr: %v", v.name, err, string(oe))
   358  	}
   359  	log.V(3).Infof("Ran command %+v on VM %v", cmd, v.name)
   360  	return string(oe), nil
   361  }
   362  
   363  // IsVM returns true if the machine is a created virtual machine
   364  func (v *VM) IsVM() bool {
   365  	return v.vmSettings != nil
   366  }
   367  
   368  // IsWS returns true if the machine is a pre-existing workstation
   369  func (v *VM) IsWS() bool {
   370  	return v.wsSettings != nil
   371  }
   372  
   373  // Sudo adds "sudo" to a command if possible for the machine
   374  func (v *VM) Sudo(cmd string) string {
   375  	if v.vmSettings != nil {
   376  		return "sudo " + cmd
   377  	}
   378  	if v.wsSettings != nil && v.wsSettings.UseSudo == true {
   379  		return "sudo " + cmd
   380  	}
   381  	return cmd
   382  }
   383  
   384  // MergeSettings merges "run" settings into "base" settings
   385  func MergeSettings(baseSettings, runSettings interface{}) {
   386  	if baseSettings == nil {
   387  		log.Fatalf("Machine settings are required in the base configuration")
   388  	}
   389  	if runSettings == nil {
   390  		// Nothing to merge
   391  		return
   392  	}
   393  
   394  	if reflect.TypeOf(baseSettings) != reflect.TypeOf(runSettings) {
   395  		log.Fatalf("Base and run configurations must have the same machine settings type: base is %T but run is %T", baseSettings, runSettings)
   396  	}
   397  
   398  	switch machineSettings := baseSettings.(type) {
   399  	case *epb.RunConfiguration_VmSettings:
   400  		run := runSettings.(*epb.RunConfiguration_VmSettings)
   401  		machineSettings.VmSettings.Zone = mergeVal(machineSettings.VmSettings.Zone, run.VmSettings.GetZone())
   402  		machineSettings.VmSettings.MachineType = mergeVal(machineSettings.VmSettings.MachineType, run.VmSettings.GetMachineType())
   403  		machineSettings.VmSettings.Image = mergeVal(machineSettings.VmSettings.Image, run.VmSettings.GetImage())
   404  		machineSettings.VmSettings.ImageOs = epb.VMSettings_OS(mergeValEnum(int32(machineSettings.VmSettings.ImageOs), int32(run.VmSettings.GetImageOs())))
   405  		machineSettings.VmSettings.ImageProject = mergeVal(machineSettings.VmSettings.ImageProject, run.VmSettings.GetImageProject())
   406  		machineSettings.VmSettings.SshUser = mergeVal(machineSettings.VmSettings.SshUser, run.VmSettings.GetSshUser())
   407  		machineSettings.VmSettings.SshKeyPath = mergeVal(machineSettings.VmSettings.SshKeyPath, run.VmSettings.GetSshKeyPath())
   408  		machineSettings.VmSettings.DiskImage = mergeVal(machineSettings.VmSettings.DiskImage, run.VmSettings.GetDiskImage())
   409  		machineSettings.VmSettings.DiskImageProject = mergeVal(machineSettings.VmSettings.DiskImageProject, run.VmSettings.GetDiskImageProject())
   410  		machineSettings.VmSettings.DiskType = mergeVal(machineSettings.VmSettings.DiskType, run.VmSettings.GetDiskType())
   411  		machineSettings.VmSettings.CreationFlags = append(machineSettings.VmSettings.CreationFlags, run.VmSettings.GetCreationFlags()...)
   412  	case *epb.RunConfiguration_WsSettings:
   413  		run := runSettings.(*epb.RunConfiguration_WsSettings)
   414  		machineSettings.WsSettings.Address = mergeVal(machineSettings.WsSettings.Address, run.WsSettings.GetAddress())
   415  		machineSettings.WsSettings.SshUser = mergeVal(machineSettings.WsSettings.SshUser, run.WsSettings.GetSshUser())
   416  		machineSettings.WsSettings.SshKeyPath = mergeVal(machineSettings.WsSettings.SshKeyPath, run.WsSettings.GetSshKeyPath())
   417  	default:
   418  		log.Fatalf("Unknown configuration type: %T (%T)", baseSettings, runSettings)
   419  	}
   420  
   421  }
   422  
   423  func mergeVal(base, v string) string {
   424  	if v == "" {
   425  		return base
   426  	}
   427  	return v
   428  }
   429  
   430  func mergeValEnum(base, v int32) int32 {
   431  	if v == 0 {
   432  		return base
   433  	}
   434  	return v
   435  }