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 }