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