This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

1//go:build linux 2 3package microvm 4 5import ( 6 "context" 7 "crypto/sha256" 8 _ "embed" 9 "encoding/hex" 10 "encoding/json" 11 "errors" 12 "fmt" 13 "log/slog" 14 "os" 15 "os/exec" 16 "path/filepath" 17 "strconv" 18 "strings" 19 "sync" 20 "time" 21 22 "github.com/digitalocean/go-qemu/qmp" 23 "github.com/google/uuid" 24) 25 26const ( 27 defaultQMPTimeout = 10 * time.Second 28 outerSlirpCIDR = "10.0.2.0/24" 29 innerSlirpNet = "10.0.3.0/24" 30 innerSlirpHost = "10.0.3.2" 31 innerSlirpDNS = "10.0.3.3" 32 innerSlirpDHCP = "10.0.3.15" 33 netnsTapName = "tap0" 34 netnsMTU = "65520" 35) 36 37type QEMUConfig struct { 38 Image ImageSpec 39 BootTimeout time.Duration 40 CID uint32 41 EnableKVM bool 42 QEMULogPath string 43 QMPPath string 44 SerialLogPath string 45 WorkDir string 46 VolumePaths map[string]string 47 VolumeBaseName string 48 Cgroup CgroupLimits 49 Dev bool 50} 51 52type QEMUVMHandle struct { 53 cid uint32 54 Process *os.Process 55 qemuLogPath string 56 QMPMon *qmp.SocketMonitor 57 QMPPath string 58 serialLogPath string 59 workDir string 60 61 qmpSocketPath string 62 63 cmd *exec.Cmd 64 done chan struct{} 65 qemuLogFile *os.File 66 cgroup *CgroupHandle 67 slirpCmd *exec.Cmd 68 slirpExit *os.File 69 waitErr error 70 waitErrMu sync.Mutex 71} 72 73type qemuRunner struct{} 74 75func (qemuRunner) Validate(spec ImageSpec, enableKVM bool) error { 76 b := newArgBuilder(len(spec.NetworkInterfaces) * 4) 77 if err := addQEMUNetworkArgs(&b, spec.NetworkInterfaces); err != nil { 78 return err 79 } 80 if _, err := exec.LookPath(spec.RunnerCmd()); err != nil { 81 return fmt.Errorf("required host command %q not found in PATH: %w", spec.RunnerCmd(), err) 82 } 83 if _, err := os.Stat("/dev/vhost-vsock"); err != nil { 84 return fmt.Errorf("microvm requires /dev/vhost-vsock for vhost-vsock-device: %w", err) 85 } 86 if enableKVM { 87 if _, err := os.Stat("/dev/kvm"); err != nil { 88 return fmt.Errorf("microvm KVM was requested but /dev/kvm is not accessible: %w", err) 89 } 90 } 91 if len(spec.NetworkInterfaces) > 0 { 92 if _, err := os.Stat("/dev/net/tun"); err != nil { 93 return fmt.Errorf("microvm slirp4netns networking requires /dev/net/tun: %w", err) 94 } 95 for _, cmd := range []string{"ip", "mount", "slirp4netns", "unshare"} { 96 if _, err := exec.LookPath(cmd); err != nil { 97 return fmt.Errorf("required host command %q not found in PATH: %w", cmd, err) 98 } 99 } 100 } 101 return nil 102} 103 104func (qemuRunner) Start(ctx context.Context, cfg VMConfig, volumePaths map[string]string, logger *slog.Logger) (VMHandle, error) { 105 bootTimeout := cfg.BootTimeout 106 if bootTimeout == 0 { 107 bootTimeout = 10 * time.Second 108 } 109 return StartQEMU(ctx, QEMUConfig{ 110 Image: cfg.Image, 111 BootTimeout: bootTimeout, 112 CID: cfg.CID, 113 EnableKVM: cfg.EnableKVM, 114 WorkDir: cfg.WorkDir, 115 VolumePaths: volumePaths, 116 Cgroup: cfg.Cgroup, 117 Dev: cfg.Dev, 118 }, logger) 119} 120 121// we hash the workDir to get a deterministic qmpSock path 122// since they are AF_UNIX sockets, they are bound by a 108 char long path limit... 123func qmpSocketPath(workDir string) string { 124 base := filepath.Dir(workDir) 125 if workDir == "" { 126 base = os.TempDir() 127 } 128 sum := sha256.Sum256([]byte(workDir)) 129 return filepath.Join(base, hex.EncodeToString(sum[:8])+".qmp.sock") 130} 131 132func StartQEMU(ctx context.Context, cfg QEMUConfig, logger *slog.Logger) (VMHandle, error) { 133 if logger == nil { 134 logger = slog.Default() 135 } 136 137 workDir := cfg.WorkDir 138 139 handle := &QEMUVMHandle{ 140 workDir: workDir, 141 } 142 143 var ok bool 144 defer func() { 145 if !ok { 146 if detail := VMCrashLog(handle); detail != "" { 147 logger.Error("microVM failed to start", "cid", handle.cid, "detail", detail) 148 } 149 _ = handle.Close() 150 } 151 }() 152 153 cid := cfg.CID 154 if cid == 0 { 155 var err error 156 cid, err = AllocateCID() 157 if err != nil { 158 return nil, err 159 } 160 } 161 if cid < minGuestCID { 162 return nil, fmt.Errorf("guest CID must be >= %d", minGuestCID) 163 } 164 handle.cid = cid 165 166 volumePaths := cfg.VolumePaths 167 168 qemuLogPath := cfg.QEMULogPath 169 if qemuLogPath == "" { 170 qemuLogPath = filepath.Join(workDir, "qemu.log") 171 } 172 qemuLogFile, err := createParentedFile(qemuLogPath) 173 if err != nil { 174 return nil, err 175 } 176 handle.qemuLogPath = qemuLogPath 177 handle.qemuLogFile = qemuLogFile 178 179 serialLogPath := cfg.SerialLogPath 180 if serialLogPath == "" { 181 serialLogPath = filepath.Join(workDir, "serial.log") 182 } 183 if err := os.MkdirAll(filepath.Dir(serialLogPath), 0o755); err != nil { 184 return nil, fmt.Errorf("create serial log directory: %w", err) 185 } 186 handle.serialLogPath = serialLogPath 187 188 qmpPath := cfg.QMPPath 189 if qmpPath == "" { 190 qmpPath = qmpSocketPath(workDir) 191 handle.qmpSocketPath = qmpPath 192 } 193 handle.QMPPath = qmpPath 194 195 qemuCmd := cfg.Image.RunnerCmd() 196 qemuBinary, err := exec.LookPath(qemuCmd) 197 if err != nil { 198 return nil, fmt.Errorf("%s command not found in PATH: %w", qemuCmd, err) 199 } 200 201 args, err := qemuArgs(qemuArgsConfig{ 202 Image: cfg.Image, 203 CID: cid, 204 EnableKVM: cfg.EnableKVM, 205 QMPPath: qmpPath, 206 SerialLogPath: serialLogPath, 207 VolumePaths: volumePaths, 208 }) 209 if err != nil { 210 return nil, err 211 } 212 213 cmd, slirpNet, err := qemuCommand(ctx, qemuBinary, args, cfg.Image, workDir, cfg.Dev) 214 if err != nil { 215 return nil, err 216 } 217 cmd.Env = append(os.Environ(), "TMPDIR="+workDir) 218 cmd.Stdout = qemuLogFile 219 cmd.Stderr = qemuLogFile 220 221 cgroup, err := prepareCgroup(cfg.Cgroup, logger) 222 if err != nil { 223 return nil, err 224 } 225 handle.cgroup = cgroup 226 227 logger.Info("starting qemu microvm", "cid", cid, "workDir", workDir, "serialLog", serialLogPath, "qmp", qmpPath) 228 if err := cmd.Start(); err != nil { 229 return nil, fmt.Errorf("starting qemu: %w", err) 230 } 231 handle.cmd = cmd 232 handle.Process = cmd.Process 233 handle.done = make(chan struct{}) 234 go func() { 235 err := cmd.Wait() 236 handle.waitErrMu.Lock() 237 handle.waitErr = err 238 handle.waitErrMu.Unlock() 239 close(handle.done) 240 }() 241 242 if err := cgroup.AddProcess(cmd.Process.Pid, logger); err != nil { 243 return nil, err 244 } 245 246 if slirpNet != nil { 247 handle.slirpCmd, handle.slirpExit, err = slirpNet.Start(ctx, qemuLogFile, logger) 248 if err != nil { 249 return nil, err 250 } 251 if handle.slirpCmd != nil && handle.slirpCmd.Process != nil { 252 if err := cgroup.AddProcess(handle.slirpCmd.Process.Pid, logger); err != nil { 253 return nil, err 254 } 255 } 256 } 257 258 qmpTimeout := cfg.BootTimeout 259 if qmpTimeout == 0 { 260 qmpTimeout = defaultQMPTimeout 261 } 262 qmpCtx, cancelQMP := context.WithTimeout(ctx, qmpTimeout) 263 defer cancelQMP() 264 if err := handle.waitForQMP(qmpCtx, qmpTimeout); err != nil { 265 return nil, err 266 } 267 268 status, err := handle.waitForQMPRunning(qmpCtx, qmpTimeout) 269 if err != nil { 270 return nil, err 271 } 272 logger.Info("qemu microvm running", "cid", cid, "status", status) 273 274 ok = true 275 return handle, nil 276} 277 278func (h *QEMUVMHandle) Wait() error { 279 if h == nil || h.done == nil { 280 return nil 281 } 282 <-h.done 283 h.waitErrMu.Lock() 284 defer h.waitErrMu.Unlock() 285 return h.waitErr 286} 287 288func (h *QEMUVMHandle) WaitContext(ctx context.Context) error { 289 if h == nil || h.done == nil { 290 return nil 291 } 292 select { 293 case <-h.done: 294 h.waitErrMu.Lock() 295 defer h.waitErrMu.Unlock() 296 return h.waitErr 297 case <-ctx.Done(): 298 return ctx.Err() 299 } 300} 301 302func (h *QEMUVMHandle) Kill() error { 303 if h == nil || h.Process == nil { 304 return nil 305 } 306 return h.Process.Kill() 307} 308 309func (h *QEMUVMHandle) Shutdown(ctx context.Context) error { 310 if h == nil { 311 return nil 312 } 313 if h.QMPMon != nil { 314 if err := h.QMPSystemPowerdown(); err != nil { 315 return err 316 } 317 } 318 if h.done == nil { 319 return nil 320 } 321 select { 322 case <-h.done: 323 return h.Wait() 324 case <-ctx.Done(): 325 _ = h.Kill() 326 _ = h.Wait() 327 return ctx.Err() 328 } 329} 330 331func (h *QEMUVMHandle) Close() error { 332 if h == nil { 333 return nil 334 } 335 336 var closeErr error 337 if h.QMPMon != nil { 338 closeErr = errors.Join(closeErr, h.QMPMon.Disconnect()) 339 h.QMPMon = nil 340 } 341 if h.Process != nil { 342 _ = h.Process.Kill() 343 _ = h.Wait() 344 } 345 if h.slirpExit != nil { 346 _ = h.slirpExit.Close() 347 h.slirpExit = nil 348 } 349 if h.slirpCmd != nil && h.slirpCmd.Process != nil { 350 _ = h.slirpCmd.Process.Kill() 351 _ = h.slirpCmd.Wait() 352 h.slirpCmd = nil 353 } 354 if h.qemuLogFile != nil { 355 closeErr = errors.Join(closeErr, h.qemuLogFile.Close()) 356 h.qemuLogFile = nil 357 } 358 if h.cgroup != nil { 359 closeErr = errors.Join(closeErr, h.cgroup.Close()) 360 h.cgroup = nil 361 } 362 if h.qmpSocketPath != "" { 363 if err := os.Remove(h.qmpSocketPath); err != nil && !os.IsNotExist(err) { 364 closeErr = errors.Join(closeErr, err) 365 } 366 h.qmpSocketPath = "" 367 } 368 return closeErr 369} 370 371func (h *QEMUVMHandle) QMPRun(command qmp.Command) ([]byte, error) { 372 if h == nil || h.QMPMon == nil { 373 return nil, fmt.Errorf("qmp monitor is not connected") 374 } 375 data, err := json.Marshal(command) 376 if err != nil { 377 return nil, err 378 } 379 return h.QMPMon.Run(data) 380} 381 382func (h *QEMUVMHandle) QMPQueryStatus() (string, error) { 383 raw, err := h.QMPRun(qmp.Command{Execute: "query-status"}) 384 if err != nil { 385 return "", fmt.Errorf("qmp query-status failed: %w", err) 386 } 387 388 var resp struct { 389 Return *struct { 390 Status string `json:"status"` 391 } `json:"return"` 392 } 393 if err := json.Unmarshal(raw, &resp); err != nil { 394 return "", fmt.Errorf("qmp query-status parse %q: %w", raw, err) 395 } 396 if resp.Return == nil { 397 return "", fmt.Errorf("qmp query-status missing return: %s", raw) 398 } 399 if resp.Return.Status == "" { 400 return "", fmt.Errorf("qmp query-status missing return.status: %s", raw) 401 } 402 return resp.Return.Status, nil 403} 404 405func (h *QEMUVMHandle) waitForQMPRunning(ctx context.Context, timeout time.Duration) (string, error) { 406 statusCtx, cancel := context.WithTimeout(ctx, timeout) 407 defer cancel() 408 409 ticker := time.NewTicker(25 * time.Millisecond) 410 defer ticker.Stop() 411 412 var lastStatus string 413 var lastErr error 414 415 for { 416 status, err := h.QMPQueryStatus() 417 if err != nil { 418 lastErr = err 419 } else { 420 lastErr = nil 421 lastStatus = status 422 423 switch status { 424 case "running": 425 return status, nil 426 case "shutdown", "internal-error", "io-error", "guest-panicked": 427 return "", fmt.Errorf("qemu guest entered unhealthy state before running (status: %s)", status) 428 } 429 } 430 431 select { 432 case <-statusCtx.Done(): 433 if lastErr != nil { 434 return "", fmt.Errorf("qemu guest did not reach running state (last status: %s): %w", lastStatus, errors.Join(statusCtx.Err(), lastErr)) 435 } 436 return "", fmt.Errorf("qemu guest did not reach running state (last status: %s): %w", lastStatus, statusCtx.Err()) 437 case <-h.done: 438 return "", fmt.Errorf("qemu exited before reaching running state: %w", h.Wait()) 439 case <-ticker.C: 440 } 441 } 442} 443 444func (h *QEMUVMHandle) QMPSystemPowerdown() error { 445 _, err := h.QMPRun(qmp.Command{Execute: "system_powerdown"}) 446 return err 447} 448 449func (h *QEMUVMHandle) Logs() VMLogs { 450 if h == nil { 451 return VMLogs{} 452 } 453 return VMLogs{ 454 Serial: h.serialLogPath, 455 Extra: map[string]string{ 456 "qemu": h.qemuLogPath, 457 }, 458 } 459} 460 461func (h *QEMUVMHandle) CID() uint32 { 462 if h == nil { 463 return 0 464 } 465 return h.cid 466} 467 468func (h *QEMUVMHandle) WorkDir() string { 469 if h == nil { 470 return "" 471 } 472 return h.workDir 473} 474 475func (h *QEMUVMHandle) OOMKilled() bool { 476 if h == nil { 477 return false 478 } 479 return h.cgroup.OOMKilled() 480} 481 482func (h *QEMUVMHandle) waitForQMP(ctx context.Context, timeout time.Duration) error { 483 qmpCtx, cancel := context.WithTimeout(ctx, timeout) 484 defer cancel() 485 486 var lastErr error 487 for { 488 mon, err := qmp.NewSocketMonitor("unix", h.QMPPath, 2*time.Second) 489 if err == nil { 490 if err = mon.Connect(); err == nil { 491 h.QMPMon = mon 492 return nil 493 } 494 _ = mon.Disconnect() 495 } 496 lastErr = err 497 498 select { 499 case <-qmpCtx.Done(): 500 return fmt.Errorf("qmp connect timeout: %w", lastErr) 501 case <-h.done: 502 return fmt.Errorf("qemu exited before qmp was ready: %w", h.Wait()) 503 case <-time.After(25 * time.Millisecond): 504 } 505 } 506} 507 508func qemuCommand( 509 ctx context.Context, 510 qemuBinary string, 511 args []string, 512 spec ImageSpec, 513 workDir string, 514 dev bool, 515) (*exec.Cmd, *slirpNamespace, error) { 516 if len(spec.NetworkInterfaces) == 0 { 517 return exec.CommandContext(ctx, qemuBinary, args...), nil, nil 518 } 519 520 ipPath, err := exec.LookPath("ip") 521 if err != nil { 522 return nil, nil, fmt.Errorf("ip command not found in PATH: %w", err) 523 } 524 mountPath, err := exec.LookPath("mount") 525 if err != nil { 526 return nil, nil, fmt.Errorf("mount command not found in PATH: %w", err) 527 } 528 unsharePath, err := exec.LookPath("unshare") 529 if err != nil { 530 return nil, nil, fmt.Errorf("unshare command not found in PATH: %w", err) 531 } 532 533 pidFile, resolvPath, wrapperPath, err := prepareQEMUNetnsFiles(workDir, dev) 534 if err != nil { 535 return nil, nil, err 536 } 537 538 cmdArgs := append([]string{ 539 "--user", 540 "--map-root-user", 541 "--net", 542 "--mount", 543 "--propagation", "private", 544 "--", 545 wrapperPath, 546 pidFile, 547 ipPath, 548 mountPath, 549 resolvPath, 550 qemuBinary, 551 }, args...) 552 553 cmd := exec.CommandContext(ctx, unsharePath, cmdArgs...) 554 555 return cmd, &slirpNamespace{ 556 spec: spec, 557 pidFile: pidFile, 558 dev: dev, 559 }, nil 560} 561 562func prepareQEMUNetnsFiles(workDir string, dev bool) (pidFile, resolvPath, wrapperPath string, err error) { 563 pidFile = filepath.Join(workDir, "qemu-netns.pid") 564 resolvPath = filepath.Join(workDir, "qemu-netns-resolv.conf") 565 wrapperPath = filepath.Join(workDir, "qemu-netns-wrapper") 566 567 // the guest resolves through shuttle on 127.0.0.1. keep qemu's slirp DNS 568 // pointed at an unroutable local resolver inside this network namespace so 569 // direct guest queries to 10.0.3.3 don't bypass the shuttle dns policy. 570 if err := os.WriteFile(resolvPath, []byte("nameserver 127.0.0.1\n"), 0o644); err != nil { 571 return "", "", "", fmt.Errorf("write qemu network namespace resolv.conf: %w", err) 572 } 573 574 if err := writeNetnsWrapper(wrapperPath, dev); err != nil { 575 return "", "", "", fmt.Errorf("write qemu network namespace wrapper: %w", err) 576 } 577 578 return pidFile, resolvPath, wrapperPath, nil 579} 580 581type qemuArgsConfig struct { 582 Image ImageSpec 583 CID uint32 584 EnableKVM bool 585 QMPPath string 586 SerialLogPath string 587 VolumePaths map[string]string 588} 589 590func qemuArgs(cfg qemuArgsConfig) ([]string, error) { 591 uuid := uuid.New() 592 593 b := newArgBuilder(64) 594 595 addQEMUMachineArgs(&b, cfg, uuid) 596 addQEMUStoreArgs(&b, cfg) 597 598 if cfg.EnableKVM { 599 addQEMUKVMArgs(&b, cfg.Image) 600 } 601 602 if err := addQEMUVolumeArgs(&b, cfg); err != nil { 603 return nil, err 604 } 605 606 if err := addQEMUNetworkArgs(&b, cfg.Image.NetworkInterfaces); err != nil { 607 return nil, err 608 } 609 610 b.Optf("-device", "vhost-vsock-device,guest-cid=%d", cfg.CID) 611 612 if len(cfg.Image.RunnerConfig.ExtraArgs) > 0 { 613 b.Add(cfg.Image.RunnerConfig.ExtraArgs...) 614 } 615 616 return b.Args(), nil 617} 618 619func addQEMUMachineArgs(b *argBuilder, cfg qemuArgsConfig, uuid uuid.UUID) { 620 if cfg.Image.RunnerConfig.Machine != "" { 621 b.Opt("-M", cfg.Image.RunnerConfig.Machine) 622 } 623 b.Optf("-m", "%dM", cfg.Image.MemoryMiB) 624 b.Opt("-smp", strconv.Itoa(cfg.Image.VCPUs)) 625 626 b.Add( 627 "-nodefaults", 628 "-no-user-config", 629 "-no-reboot", 630 ) 631 632 b.Opt("-kernel", cfg.Image.Kernel) 633 b.Opt("-initrd", cfg.Image.Initrd) 634 635 b.Opt("-device", "virtio-rng-device") 636 637 b.Optf("-smbios", "type=1,uuid=%s", uuid) 638 b.Opt("-serial", "file:"+cfg.SerialLogPath) 639 640 // use virtio console if requsted. this is faster than the serial UART logging 641 // because serial has a higher cost when being accesssed. we still have to 642 // support serial itself for early kernel boot but thats OK. 643 if cfg.Image.RunnerConfig.Console == "hvc0" { 644 b.Optf("-chardev", "file,id=virtiocon0,path=%s,append=on", cfg.SerialLogPath) 645 b.Add("-device", "virtio-serial-device") 646 b.Opt("-device", "virtconsole,chardev=virtiocon0") 647 } 648 b.Opt("-display", "none") 649 b.Opt("-monitor", "none") 650 b.Opt("-append", cfg.Image.BootArgs) 651 652 b.Opt("-sandbox", "on") 653 b.Optf("-qmp", "unix:%s,server,nowait", cfg.QMPPath) 654} 655 656func addQEMUStoreArgs(b *argBuilder, cfg qemuArgsConfig) { 657 drive := newOptionBuilder(8) 658 drive.KV("id", "store") 659 drive.KV("format", "raw") 660 drive.Add("read-only=on") 661 drive.KV("file", cfg.Image.StoreDisk) 662 drive.Add("if=none") 663 drive.Add("aio=io_uring") 664 665 b.Opt("-drive", drive.String()) 666 b.Opt("-device", "virtio-blk-device,drive=store") 667} 668 669func addQEMUKVMArgs(b *argBuilder, image ImageSpec) { 670 b.Flag("-enable-kvm") 671 if image.RunnerConfig.CPU != "" { 672 b.Opt("-cpu", image.RunnerConfig.CPU) 673 } 674 b.Opt("-device", "i8042") 675} 676 677func addQEMUVolumeArgs(b *argBuilder, cfg qemuArgsConfig) error { 678 for index, volume := range cfg.Image.Volumes { 679 path := cfg.VolumePaths[volume.Image] 680 if path == "" { 681 return fmt.Errorf("missing prepared path for volume %q", volume.Image) 682 } 683 684 driveID := fmt.Sprintf("volume%d", index) 685 686 drive := newOptionBuilder(10) 687 drive.KV("id", driveID) 688 drive.KV("format", "raw") 689 drive.Add("read-only=off") 690 drive.KV("file", path) 691 drive.Add("if=none") 692 drive.Add("aio=io_uring") 693 drive.Add("discard=unmap") 694 drive.Add("cache=none") 695 696 b.Opt("-drive", drive.String()) 697 b.Optf("-device", "virtio-blk-device,drive=%s", driveID) 698 } 699 700 return nil 701} 702 703func addQEMUNetworkArgs(b *argBuilder, interfaces []NetworkInterface) error { 704 for _, networkInterface := range interfaces { 705 if networkInterface.Type != "slirp4netns" { 706 return fmt.Errorf("unsupported microvm network interface type %q", networkInterface.Type) 707 } 708 709 netdevOpts := newOptionBuilder(6) 710 netdevOpts.Add("user") 711 netdevOpts.KV("id", networkInterface.ID) 712 netdevOpts.KV("net", innerSlirpNet) 713 netdevOpts.KV("host", innerSlirpHost) 714 netdevOpts.KV("dns", innerSlirpDNS) 715 netdevOpts.KV("dhcpstart", innerSlirpDHCP) 716 717 b.Opt("-netdev", netdevOpts.String()) 718 b.Optf( 719 "-device", "virtio-net-device,netdev=%s,mac=%s", 720 networkInterface.ID, networkInterface.MAC, 721 ) 722 } 723 724 return nil 725} 726 727func waitForPIDFile(ctx context.Context, path string) (string, error) { 728 waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second) 729 defer cancel() 730 731 ticker := time.NewTicker(25 * time.Millisecond) 732 defer ticker.Stop() 733 734 for { 735 data, err := os.ReadFile(path) 736 if err == nil { 737 pid := strings.TrimSpace(string(data)) 738 if pid != "" { 739 return pid, nil 740 } 741 } else if !errors.Is(err, os.ErrNotExist) { 742 return "", fmt.Errorf("read qemu network namespace pid: %w", err) 743 } 744 745 select { 746 case <-waitCtx.Done(): 747 return "", fmt.Errorf("waiting for qemu network namespace pid: %w", waitCtx.Err()) 748 case <-ticker.C: 749 } 750 } 751}