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 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}