diff --git a/cmd/image/handler.go b/cmd/image/handler.go index adf4e8d..0686eff 100644 --- a/cmd/image/handler.go +++ b/cmd/image/handler.go @@ -23,7 +23,8 @@ type Handler struct{} func NewHandler() *Handler { return &Handler{} } func (h *Handler) Pull(cmd *cobra.Command, args []string) error { - ctx, s, err := home.OpenStore(cmd) + ctx := cliutil.CommandContext(cmd) + s, err := home.OpenStore(ctx, cmd) if err != nil { return err } @@ -68,7 +69,8 @@ func (h *Handler) Pull(cmd *cobra.Command, args []string) error { } func (h *Handler) List(cmd *cobra.Command, _ []string) error { - ctx, s, err := home.OpenStore(cmd) + ctx := cliutil.CommandContext(cmd) + s, err := home.OpenStore(ctx, cmd) if err != nil { return err } @@ -77,9 +79,9 @@ func (h *Handler) List(cmd *cobra.Command, _ []string) error { return err } return cliutil.OutputFormatted(cmd, imgs, func(w *tabwriter.Writer) { - fmt.Fprintln(w, "NAME\tTYPE\tSIZE\tDIGEST\tCREATED") //nolint:errcheck + fmt.Fprintln(w, "NAME\tTYPE\tSIZE\tDIGEST\tCREATED") //nolint:errcheck // the tabwriter flush reports the write error for _, img := range imgs { - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", //nolint:errcheck + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", //nolint:errcheck // the tabwriter flush reports the write error img.Name, img.Type, cliutil.FormatSize(img.Size), shortDigest(img.ID), img.CreatedAt.Local().Format(time.DateTime)) } @@ -87,7 +89,8 @@ func (h *Handler) List(cmd *cobra.Command, _ []string) error { } func (h *Handler) Inspect(cmd *cobra.Command, args []string) error { - ctx, s, err := home.OpenStore(cmd) + ctx := cliutil.CommandContext(cmd) + s, err := home.OpenStore(ctx, cmd) if err != nil { return err } @@ -102,7 +105,8 @@ func (h *Handler) Inspect(cmd *cobra.Command, args []string) error { } func (h *Handler) RM(cmd *cobra.Command, args []string) error { - ctx, s, err := home.OpenStore(cmd) + ctx := cliutil.CommandContext(cmd) + s, err := home.OpenStore(ctx, cmd) if err != nil { return err } diff --git a/cmd/vm/clone.go b/cmd/vm/clone.go index a601fd2..8c2e248 100644 --- a/cmd/vm/clone.go +++ b/cmd/vm/clone.go @@ -41,7 +41,10 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { } func (h *Handler) clone(cmd *cobra.Command, srcRec *record, name string) (retErr error) { - netMode, _ := cmd.Flags().GetString("net") + netMode := srcRec.NetMode + if cmd.Flags().Changed("net") { + netMode, _ = cmd.Flags().GetString("net") + } vnc, _ := cmd.Flags().GetInt("vnc") vncPass, _ := cmd.Flags().GetString("vnc-password") if err := requireCNIVNCPassword(netMode == netCNI, vnc, vncPass); err != nil { @@ -86,7 +89,7 @@ func (h *Handler) clone(cmd *cobra.Command, srcRec *record, name string) (retErr if err != nil { return err } - copied, err := copyDataDisks(dir, srcRec.DataDisks) + copied, err := copyDataDisks(ctx, dir, srcRec.DataDisks) if err != nil { return err } @@ -128,6 +131,11 @@ func (h *Handler) clone(cmd *cobra.Command, srcRec *record, name string) (retErr r.MAC = srcRec.MAC // prepareOpenCore only sets a fresh MAC for fresh identities } r.NetMode = netMode + if bridge, _ := cmd.Flags().GetString("bridge"); bridge != "" { + r.BridgeDev = bridge + } else if !cmd.Flags().Changed("net") { + r.BridgeDev = srcRec.BridgeDev + } tapFlag, _ := cmd.Flags().GetString("tap") r.Tap = tapFlag if err = applyNet(cmd, r); err != nil { diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 3327fa2..71520e9 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -33,7 +33,7 @@ func Command(h *Handler) *cobra.Command { RunE: h.Start, } startCmd.Flags().Int("vnc", -1, "VNC display number for this start only (n => port 590n); omit to keep VNC off") - startCmd.Flags().String("vnc-password", "", "VNC password for this start (≤8 chars, QEMU password auth)") + startCmd.Flags().String("vnc-password", "", "VNC password for this start (≤8 bytes, QEMU password auth)") stopCmd := &cobra.Command{ Use: "stop VM [VM...]", @@ -89,11 +89,11 @@ func Command(h *Handler) *cobra.Command { } restoreCmd.Flags().String("tag", "", "snapshot tag to restore (default: newest)") restoreCmd.Flags().Bool("force", false, "stop the VM, restore, then relaunch if it was running") - restoreCmd.Flags().String("vnc-password", "", "VNC password for the relaunch of a running VM whose display is password-gated (≤8 chars)") + restoreCmd.Flags().String("vnc-password", "", "VNC password for the relaunch of a running VM whose display is password-gated (≤8 bytes)") cloneCmd := &cobra.Command{ Use: "clone SRC", - Short: "Clone a stopped VM: fresh CoW overlay on the shared base + a unique Apple identity + its own TAP", + Short: "Clone a stopped VM: fresh CoW overlay on the shared base + a unique Apple identity + its own network endpoint (--net inherited from the source)", Args: cobra.ExactArgs(1), RunE: h.Clone, } @@ -119,7 +119,7 @@ func addVMFlags(cmd *cobra.Command) { cmd.Flags().String("ovmf-code", "", "OVMF_CODE firmware (default: /firmware/OVMF_CODE.fd)") cmd.Flags().String("ovmf-vars", "", "OVMF_VARS template, copied per-VM (default: /firmware/OVMF_VARS.fd)") cmd.Flags().Bool("random-smbios", false, "inject a unique Apple SMBIOS identity per VM (serial/MLB/UUID/ROM)") - cmd.Flags().String("vnc-password", "", "set a VNC password (≤8 chars) so macOS Screen Sharing can connect (QEMU password auth)") + cmd.Flags().String("vnc-password", "", "set a VNC password (≤8 bytes) so macOS Screen Sharing can connect (QEMU password auth)") cmd.Flags().String("net", "user", "network mode: user (SLIRP + --ssh-port hostfwd) | tap | cni | bridge (cocoon auto-creates the TAP, Linux only)") cmd.Flags().String("tap", "", "pre-created host TAP ifname (skips auto-create; e.g. an existing bridge port / cocoon CNI tap)") cmd.Flags().String("bridge", "", "existing Linux bridge to enslave the auto-created TAP to (--net tap|bridge)") diff --git a/cmd/vm/datadisk.go b/cmd/vm/datadisk.go index 38d5029..8cc7376 100644 --- a/cmd/vm/datadisk.go +++ b/cmd/vm/datadisk.go @@ -113,11 +113,11 @@ func createDataDisks(ctx context.Context, dir string, specs []types.DataDiskSpec return paths, nil } -func copyDataDisks(dir string, src []string) ([]string, error) { +func copyDataDisks(ctx context.Context, dir string, src []string) ([]string, error) { paths := make([]string, 0, len(src)) for _, srcPath := range src { dst := filepath.Join(dir, filepath.Base(srcPath)) - if err := utils.ReflinkCopy(dst, srcPath, utils.Sync); err != nil { + if err := utils.ReflinkCopy(ctx, dst, srcPath, utils.Sync); err != nil { return nil, fmt.Errorf("copy data disk %s: %w", filepath.Base(srcPath), err) } paths = append(paths, dst) diff --git a/cmd/vm/datadisk_test.go b/cmd/vm/datadisk_test.go index 6cb849d..8585116 100644 --- a/cmd/vm/datadisk_test.go +++ b/cmd/vm/datadisk_test.go @@ -36,7 +36,6 @@ func TestParseDataDisks(t *testing.T) { {name: "directio rejected on macOS", raw: []string{"size=1G,directio=on"}, wantErr: true}, {name: "over the four-disk cap", raw: []string{"size=1G", "size=1G", "size=1G", "size=1G", "size=1G"}, wantErr: true}, - // clone reserves the copied disks' names: collisions error and reserved count against the cap {name: "collides with a reserved name", raw: []string{"name=data1,size=1G"}, reserved: []string{"data1"}, wantErr: true}, {name: "auto name skips reserved", raw: []string{"size=1G"}, reserved: []string{"data1"}, wantNames: []string{"data2"}, wantSizes: []int64{gib}}, {name: "reserved fills the cap", raw: []string{"size=1G", "size=1G"}, reserved: []string{"data1", "data2", "data3"}, wantErr: true}, @@ -69,7 +68,6 @@ func TestParseDataDisks(t *testing.T) { } func TestDataDiskNameRoundTrip(t *testing.T) { - // clone recovers reserved names from the copied disks' paths, so this must invert dataDiskPath for _, name := range []string{"data1", "logs", "a-b_c"} { if got := dataDiskName(dataDiskPath("/vm/dir", name)); got != name { t.Errorf("dataDiskName(dataDiskPath(%q)) = %q", name, got) diff --git a/cmd/vm/handler_test.go b/cmd/vm/handler_test.go index 0b3e7b5..c355434 100644 --- a/cmd/vm/handler_test.go +++ b/cmd/vm/handler_test.go @@ -41,7 +41,6 @@ func TestStartAlreadyRunningIsIdempotent(t *testing.T) { t.Fatal(err) } - // A real process whose argv0+args satisfy isRunning without qemu/KVM. fakeQEMU := filepath.Join(t.TempDir(), qemuBinary) if err := os.Symlink("/bin/sh", fakeQEMU); err != nil { t.Fatal(err) @@ -155,9 +154,7 @@ func TestImagesToSnapshot(t *testing.T) { rec *record want []string }{ - // raw .fd NVRAM can't hold internal snapshots, so only the disk is captured {"raw nvram captures disk only", &record{Disk: "/v/disk.qcow2", OVMFVars: "/v/OVMF_VARS.fd"}, []string{"/v/disk.qcow2"}}, - // a qcow2 NVRAM rolls back too {"qcow2 nvram captures both", &record{Disk: "/v/disk.qcow2", OVMFVars: "/v/OVMF_VARS.qcow2"}, []string{"/v/disk.qcow2", "/v/OVMF_VARS.qcow2"}}, } for _, tt := range tests { @@ -169,7 +166,6 @@ func TestImagesToSnapshot(t *testing.T) { } } -// auto-create/CNI/bridge need Linux + CAP_NET_ADMIN; they are smoke-tested on the testbed. func TestPrepareNetNoProvision(t *testing.T) { tests := []struct { name string diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index 49b9eec..4ac18a7 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -19,38 +19,11 @@ import ( ) func (h *Handler) Create(cmd *cobra.Command, args []string) error { - name := requestedVMName(cmd, "macos-"+time.Now().Format("20060102-150405")) - dir, err := home.VMDir(cmd, name) - if err != nil { - return err - } - return withVMLock(cliutil.CommandContext(cmd), dir, func() error { - r, err := h.create(cmd, args[0], name) - if err != nil { - return err - } - fmt.Println(r.Name) - return nil - }) + return h.createVM(cmd, args[0], false) } func (h *Handler) Run(cmd *cobra.Command, args []string) error { - name := requestedVMName(cmd, "macos-"+time.Now().Format("20060102-150405")) - dir, err := home.VMDir(cmd, name) - if err != nil { - return err - } - return withVMLock(cliutil.CommandContext(cmd), dir, func() error { - r, err := h.create(cmd, args[0], name) - if err != nil { - return err - } - if err := h.launch(cmd, dir, r); err != nil { - return errors.Join(err, cleanupFailedVM(cmd, dir, r)) - } - fmt.Printf("%s (pid %d)\n", r.Name, r.PID) - return nil - }) + return h.createVM(cmd, args[0], true) } func (h *Handler) Start(cmd *cobra.Command, args []string) error { @@ -89,7 +62,7 @@ func (h *Handler) Start(cmd *cobra.Command, args []string) error { if err := h.launch(cmd, dir, r); err != nil { return err } - unquiesceNet(cmd, r) + toggleNet(cmd, r, true) fmt.Printf("%s (pid %d)\n", n, r.PID) return nil }); err != nil { @@ -116,7 +89,7 @@ func (h *Handler) Stop(cmd *cobra.Command, args []string) error { return err } terminate(ctx, r, grace) - quiesceNet(cmd, r) + toggleNet(cmd, r, false) stopVNCProxy(ctx, dir) r.PID, r.VNCDisp, r.VNCPass, r.VNCPassSet = 0, -1, "", false // VNC is launch-scoped: gone with the qemu it belonged to return saveRec(dir, r) @@ -155,10 +128,7 @@ func (h *Handler) RM(cmd *cobra.Command, args []string) error { } else { cleanupCtx, cancel := context.WithTimeout(context.Background(), vmCleanupTimeout) defer cancel() - if cleanupErr := procutil.TerminateByCmdline(cleanupCtx, qemuBinary, dir, 0); cleanupErr != nil { - return cleanupErr - } - if cleanupErr := procutil.TerminateByCmdline(cleanupCtx, "qemu-nbd", dir, time.Second); cleanupErr != nil { + if cleanupErr := reapStrayHelpers(cleanupCtx, dir); cleanupErr != nil { return cleanupErr } } @@ -174,6 +144,29 @@ func (h *Handler) RM(cmd *cobra.Command, args []string) error { return nil } +func (h *Handler) createVM(cmd *cobra.Command, image string, launch bool) error { + name := requestedVMName(cmd, "macos-"+time.Now().Format("20060102-150405")) + dir, err := home.VMDir(cmd, name) + if err != nil { + return err + } + return withVMLock(cliutil.CommandContext(cmd), dir, func() error { + r, err := h.create(cmd, image, name) + if err != nil { + return err + } + if !launch { + fmt.Println(r.Name) + return nil + } + if err := h.launch(cmd, dir, r); err != nil { + return errors.Join(err, cleanupFailedVM(cmd, dir, r)) + } + fmt.Printf("%s (pid %d)\n", r.Name, r.PID) + return nil + }) +} + func (h *Handler) create(cmd *cobra.Command, image, name string) (r *record, retErr error) { rawDisks, _ := cmd.Flags().GetStringArray("data-disk") diskSpecs, err := parseDataDisks(rawDisks, nil) // fail fast before any scaffolding @@ -330,10 +323,7 @@ func cleanupFailedVM(cmd *cobra.Command, dir string, r *record) error { errs = append(errs, err) } } - if err := procutil.TerminateByCmdline(ctx, qemuBinary, dir, 0); err != nil { - errs = append(errs, err) - } - if err := procutil.TerminateByCmdline(ctx, "qemu-nbd", dir, time.Second); err != nil { + if err := reapStrayHelpers(ctx, dir); err != nil { errs = append(errs, err) } if err := os.RemoveAll(dir); err != nil { @@ -342,6 +332,13 @@ func cleanupFailedVM(cmd *cobra.Command, dir string, r *record) error { return errors.Join(errs...) } +func reapStrayHelpers(ctx context.Context, dir string) error { + return errors.Join( + procutil.TerminateByCmdline(ctx, qemuBinary, vmDirPrefix(dir), 0), + procutil.TerminateByCmdline(ctx, "qemu-nbd", vmDirPrefix(dir), time.Second), + ) +} + // prepareOpenCore points r.OpenCore at the shared base, or with randomSMBIOS at a per-VM overlay whose config.plist is patched with a unique identity. func prepareOpenCore(ctx context.Context, dir, ocBase string, randomSMBIOS bool, r *record) error { if !randomSMBIOS { diff --git a/cmd/vm/net_linux.go b/cmd/vm/net_linux.go index 2605126..4350f9f 100644 --- a/cmd/vm/net_linux.go +++ b/cmd/vm/net_linux.go @@ -74,12 +74,18 @@ func provisionNet(cmd *cobra.Command, r *record) (tap, netns, mac string, err er return "", "", "", fmt.Errorf("prepare network: %w", err) } cfgs, err := provider.Add(ctx, r.VMID, vmCfg, network.AddRange(0, 1)...) + if err == nil && len(cfgs) == 0 { + err = errors.New("network add returned no NIC") + } if err != nil { + // Add rolls back only what it created itself; the netns from Prepare is ours to drop + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), vmCleanupTimeout) + defer cancel() + if delErr := provider.Delete(rctx, r.VMID); delErr != nil { + log.WithFunc("cmd.vm.provisionNet").Warnf(rctx, "rollback network for %s: %v", r.VMID, delErr) + } return "", "", "", fmt.Errorf("add network: %w", err) } - if len(cfgs) == 0 { - return "", "", "", errors.New("network add returned no NIC") - } mac = cfgs[0].MAC if r.NetMode != netCNI { mac = cmp.Or(r.MAC, mac) @@ -108,39 +114,29 @@ func teardownNet(ctx context.Context, cmd *cobra.Command, r *record) error { if err := provider.Quiesce(ctx, r.VMID); err != nil { logger.Warnf(ctx, "quiesce network for %s: %v", r.VMID, err) } - if _, err := provider.Delete(ctx, []string{r.VMID}); err != nil { + if err := provider.Delete(ctx, r.VMID); err != nil { return fmt.Errorf("teardown network for %s: %w", r.VMID, err) } return nil } -// quiesceNet downs a stopped VM's owned NICs so a dead VMM's carrier-less TAP can't storm host softirqs via the tc mirred redirect; unquiesceNet reverses it on start. -func quiesceNet(cmd *cobra.Command, r *record) { +// toggleNet downs a stopped VM's owned NICs so a dead VMM's carrier-less TAP can't storm host softirqs via the tc mirred redirect, and brings them back up on start. +func toggleNet(cmd *cobra.Command, r *record, up bool) { if !r.TapOwned { return } ctx := cliutil.CommandContext(cmd) - logger := log.WithFunc("cmd.vm.quiesceNet") - if provider, err := newProvider(cmd, r); err != nil { - logger.Warnf(ctx, "quiesce network for %s: %v", r.VMID, err) - } else if err := provider.Quiesce(ctx, r.VMID); err != nil { - logger.Warnf(ctx, "quiesce network for %s: %v", r.VMID, err) + logger := log.WithFunc("cmd.vm.toggleNet") + verb, toggle := "quiesce", network.Network.Quiesce + if up { + verb, toggle = "unquiesce", network.Network.Unquiesce } - setTapLink(ctx, r, false) -} - -func unquiesceNet(cmd *cobra.Command, r *record) { - if !r.TapOwned { - return - } - ctx := cliutil.CommandContext(cmd) - logger := log.WithFunc("cmd.vm.unquiesceNet") if provider, err := newProvider(cmd, r); err != nil { - logger.Warnf(ctx, "unquiesce network for %s: %v", r.VMID, err) - } else if err := provider.Unquiesce(ctx, r.VMID); err != nil { - logger.Warnf(ctx, "unquiesce network for %s: %v", r.VMID, err) + logger.Warnf(ctx, "%s network for %s: %v", verb, r.VMID, err) + } else if err := toggle(provider, ctx, r.VMID); err != nil { + logger.Warnf(ctx, "%s network for %s: %v", verb, r.VMID, err) } - setTapLink(ctx, r, true) + setTapLink(ctx, r, up) } // setTapLink flips a host-netns TAP's admin state: cocoon's bridge backend no-ops Quiesce, so the toggle lives here; a CNI TAP is inside a netns and is the provider's job. diff --git a/cmd/vm/net_other.go b/cmd/vm/net_other.go index 279f620..0300911 100644 --- a/cmd/vm/net_other.go +++ b/cmd/vm/net_other.go @@ -17,9 +17,7 @@ func provisionNet(_ *cobra.Command, _ *record) (tap, netns, mac string, err erro func teardownNet(_ context.Context, _ *cobra.Command, _ *record) error { return nil } -func quiesceNet(_ *cobra.Command, _ *record) {} - -func unquiesceNet(_ *cobra.Command, _ *record) {} +func toggleNet(_ *cobra.Command, _ *record, _ bool) {} func launchCmd(_ *record, args []string) *exec.Cmd { return exec.Command(qemuBinary, args...) diff --git a/cmd/vm/query.go b/cmd/vm/query.go index f9afe08..c8254a3 100644 --- a/cmd/vm/query.go +++ b/cmd/vm/query.go @@ -28,9 +28,9 @@ func (h *Handler) List(cmd *cobra.Command, _ []string) error { } } return cliutil.OutputFormatted(cmd, recs, func(w *tabwriter.Writer) { - fmt.Fprintln(w, "NAME\tSTATE\tCPU\tMEM\tNET\tVNC\tSSH\tIMAGE\tCREATED") //nolint:errcheck + fmt.Fprintln(w, "NAME\tSTATE\tCPU\tMEM\tNET\tVNC\tSSH\tIMAGE\tCREATED") //nolint:errcheck // the tabwriter flush reports the write error for _, r := range recs { - fmt.Fprintf(w, "%s\t%s\t%d\t%sM\t%s\t%s\t%s\t%s\t%s\n", //nolint:errcheck + fmt.Fprintf(w, "%s\t%s\t%d\t%sM\t%s\t%s\t%s\t%s\t%s\n", //nolint:errcheck // the tabwriter flush reports the write error r.Name, vmState(r), r.CPUs, r.Memory, cmp.Or(r.NetMode, netUser), vncCol(r), sshCol(r), r.Image, formatTime(r.Created)) } diff --git a/cmd/vm/utils.go b/cmd/vm/utils.go index e67bb8d..a10e6d6 100644 --- a/cmd/vm/utils.go +++ b/cmd/vm/utils.go @@ -143,7 +143,8 @@ func scaffoldVM(cmd *cobra.Command, name, image, varsSrc, varsName string) (dir, if err = resetIncompleteVMDir(cleanupCtx, dir); err != nil { return "", "", "", "", err } - base, digest, err := resolveBase(cmd, image, name) + ctx := cliutil.CommandContext(cmd) + base, digest, err := resolveBase(ctx, cmd, image, name) if err != nil { return "", "", "", "", err } @@ -156,11 +157,11 @@ func scaffoldVM(cmd *cobra.Command, name, image, varsSrc, varsName string) (dir, } }() overlay = filepath.Join(dir, "disk.qcow2") - if err = bakeOverlay(cliutil.CommandContext(cmd), base, overlay); err != nil { + if err = bakeOverlay(ctx, base, overlay); err != nil { return "", "", "", "", err } ovmfVars = filepath.Join(dir, varsName) - if err = utils.ReflinkCopy(ovmfVars, varsSrc, utils.Sync); err != nil { + if err = utils.ReflinkCopy(ctx, ovmfVars, varsSrc, utils.Sync); err != nil { return "", "", "", "", fmt.Errorf("copy OVMF_VARS: %w", err) } return dir, overlay, ovmfVars, digest, nil @@ -178,12 +179,12 @@ func resetIncompleteVMDir(ctx context.Context, dir string) error { } else if !os.IsNotExist(err) { return fmt.Errorf("stat vm record: %w", err) } - if pids, err := utils.FindVMMByCmdline(qemuBinary, dir); err != nil { + if pids, err := utils.FindVMMByCmdline(qemuBinary, vmDirPrefix(dir)); err != nil { return fmt.Errorf("scan qemu processes for %s: %w", dir, err) } else if len(pids) > 0 { return fmt.Errorf("refuse to replace incomplete vm dir %s: live qemu pids %v", dir, pids) } - if err := procutil.TerminateByCmdline(ctx, "qemu-nbd", dir, time.Second); err != nil { + if err := procutil.TerminateByCmdline(ctx, "qemu-nbd", vmDirPrefix(dir), time.Second); err != nil { return fmt.Errorf("cleanup stale qemu-nbd for %s: %w", dir, err) } if err := os.RemoveAll(dir); err != nil { @@ -192,6 +193,9 @@ func resetIncompleteVMDir(ctx context.Context, dir string) error { return nil } +// vmDirPrefix is the cmdline needle for a VM dir: the separator keeps foo from matching foo-clone. +func vmDirPrefix(dir string) string { return dir + "/" } + // prepareNet returns the TAP ifname, netns path (CNI only), and guest MAC; user-mode and a pre-created --tap need no provisioning, every other mode goes through the per-OS provisionNet. func prepareNet(cmd *cobra.Command, r *record) (tap, netns, mac string, err error) { switch r.NetMode { @@ -275,12 +279,12 @@ func hostIsAMD() bool { } // resolveBase returns the immutable base qcow2 (+ digest): a direct filesystem path, else an image ref resolved through cocoon's cloudimg store. -func resolveBase(cmd *cobra.Command, image, name string) (string, string, error) { +func resolveBase(ctx context.Context, cmd *cobra.Command, image, name string) (string, string, error) { if _, err := os.Stat(image); err == nil { return image, "", nil } ensureCloudimgFirmware(cmd) - ctx, store, err := home.OpenStore(cmd) + store, err := home.OpenStore(ctx, cmd) if err != nil { return "", "", err } diff --git a/cmd/vm/utils_test.go b/cmd/vm/utils_test.go index 036bf44..402d68d 100644 --- a/cmd/vm/utils_test.go +++ b/cmd/vm/utils_test.go @@ -3,6 +3,7 @@ package vm import ( "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -139,3 +140,13 @@ func TestGraceFromFlags(t *testing.T) { }) } } + +func TestVMDirPrefixSeparatesSiblingNames(t *testing.T) { + needle := vmDirPrefix("/state/vms/foo") + if !strings.Contains("qemu\x00-drive\x00file=/state/vms/foo/disk.qcow2", needle) { + t.Fatal("own disk path not matched") + } + if strings.Contains("qemu\x00-drive\x00file=/state/vms/foo-clone/disk.qcow2", needle) { + t.Fatal("sibling foo-clone matched") + } +} diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go index 36ced90..870e7af 100644 --- a/cmd/vm/vnc.go +++ b/cmd/vm/vnc.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "syscall" "time" "unicode" @@ -27,7 +28,17 @@ const ( vncBasePort = 5900 ) -var errCNIVNCPassRequired = errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required") +var ( + errCNIVNCPassRequired = errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required") + + // proxyBinaryName matches how startVNCProxy spawns the proxy: by the resolved executable, not argv[0]. + proxyBinaryName = sync.OnceValue(func() string { + if self, err := os.Executable(); err == nil { + return filepath.Base(self) + } + return filepath.Base(os.Args[0]) + }) +) // requireCNIVNCPassword rejects an unauthenticated VNC display on a CNI VM (the proxy listens on 0.0.0.0); isCNI is the flag intent at create/clone or the resolved Netns at launch. func requireCNIVNCPassword(isCNI bool, vncDisp int, vncPass string) error { @@ -37,13 +48,13 @@ func requireCNIVNCPassword(isCNI bool, vncDisp int, vncPass string) error { return validateVNCPassword(vncPass) } -// validateVNCPassword rejects control characters (a newline would inject a second HMP command) and enforces QEMU's 8-char VNC limit. +// validateVNCPassword enforces QEMU's 8-byte VNC limit and keeps the password a single HMP token: a space ends it, a newline starts a second command. func validateVNCPassword(pw string) error { if len(pw) > 8 { - return fmt.Errorf("--vnc-password must be at most 8 characters, got %d", len(pw)) + return fmt.Errorf("--vnc-password must be at most 8 bytes, got %d", len(pw)) } - if strings.ContainsFunc(pw, unicode.IsControl) { - return errors.New("--vnc-password must not contain control characters") + if strings.ContainsFunc(pw, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) }) { + return errors.New("--vnc-password must not contain whitespace or control characters") } return nil } @@ -96,14 +107,14 @@ func startVNCProxy(ctx context.Context, dir string, disp int) error { func vncProxyRunning(dir string) bool { pid, err := utils.ReadPIDFile(filepath.Join(dir, vncProxyPID)) - return err == nil && utils.VerifyProcessCmdline(pid, filepath.Base(os.Args[0]), filepath.Join(dir, vncSockName)) + return err == nil && utils.VerifyProcessCmdline(pid, proxyBinaryName(), filepath.Join(dir, vncSockName)) } // stopVNCProxy kills a running proxy (best-effort). Zero grace: the proxy traps SIGTERM via the root NotifyContext and would keep accepting, and SIGKILL loses nothing on a stateless pipe. func stopVNCProxy(ctx context.Context, dir string) { pidPath := filepath.Join(dir, vncProxyPID) if pid, err := utils.ReadPIDFile(pidPath); err == nil { - _ = utils.TerminateProcess(ctx, pid, filepath.Base(os.Args[0]), filepath.Join(dir, vncSockName), 0) + _ = utils.TerminateProcess(ctx, pid, proxyBinaryName(), filepath.Join(dir, vncSockName), 0) } _ = os.Remove(pidPath) } diff --git a/docs/networking.md b/docs/networking.md index 75b4faf..7c8c7d6 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -33,6 +33,10 @@ goes straight to that IP (no port-forward). `/opt/cni/bin`) point at a non-standard CNI installation; both are ignored by the other net modes. +## Clones + +`vm clone` inherits the source's `--net` mode, and for `bridge` its bridge, unless `--net` or `--bridge` is given; every clone gets its own TAP (and netns under `cni`) and, with a fresh identity, its own MAC. A source attached to a pre-created host TAP (`--tap tap0`) cannot share it, so its clone needs its own `--tap`; a `tap` source that auto-created its TAP on a bridge clones like `bridge`. + ## VNC exposure VNC exposure depends on the net mode: @@ -56,9 +60,9 @@ The VNC password is never written to disk — it is read from the flag on each s ### macOS Screen Sharing -QEMU's default `None` auth **hangs macOS Screen Sharing**. Pass `--vnc-password <≤8 chars>` (applied +QEMU's default `None` auth **hangs macOS Screen Sharing**. Pass `--vnc-password <≤8 bytes>` (applied via the QEMU monitor post-launch) so Screen Sharing prompts and connects. The launch is **rejected** -(not truncated) if the password exceeds 8 characters or contains control characters — QEMU's VNC DES +(not truncated) if the password exceeds 8 bytes or contains whitespace or control characters — QEMU's VNC DES auth only supports 8-byte passwords. Plain VNC clients (RealVNC/TigerVNC) work without a password on the loopback modes. diff --git a/go.mod b/go.mod index e76c3a8..b176c7f 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cocoonstack/cocoon-macos go 1.27.0 require ( - github.com/cocoonstack/cocoon v0.6.1 + github.com/cocoonstack/cocoon v0.6.2-0.20260902190340-51ff88bcf8f1 github.com/docker/go-units v0.5.0 github.com/opencontainers/image-spec v1.1.1 github.com/projecteru2/core v0.1.3 @@ -20,22 +20,31 @@ require ( github.com/cockroachdb/redact v1.1.8 // indirect github.com/containernetworking/cni v1.3.0 // indirect github.com/containernetworking/plugins v1.9.1 // indirect + github.com/creack/pty v1.1.24 // indirect + github.com/docker/cli v29.6.2+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.8 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/getsentry/sentry-go v0.48.0 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-containerregistry v0.21.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/onsi/ginkgo/v2 v2.27.4 // indirect github.com/onsi/gomega v1.39.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/rs/zerolog v1.35.1 // indirect + github.com/sirupsen/logrus v1.10.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/vishvananda/netns v0.0.5 // indirect golang.org/x/sys v0.47.0 // indirect @@ -44,4 +53,8 @@ require ( google.golang.org/grpc v1.83.1 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + modernc.org/libc v1.74.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.54.0 // indirect ) diff --git a/go.sum b/go.sum index 3762b41..67bd2f4 100644 --- a/go.sum +++ b/go.sum @@ -6,16 +6,24 @@ github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 h1:ASDL+UJcILM github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506/go.mod h1:Mw7HqKr2kdtu6aYGn3tPmAftiP3QPX63LdK/zcariIo= github.com/cockroachdb/redact v1.1.8 h1:8eVLLj6juKxiKrAEw2b8cJvNqWq++U8WOfQFuL7KTaA= github.com/cockroachdb/redact v1.1.8/go.mod h1:GceHHpJ0rMDpYARL5In88Alq/xMBUtVlz7Qxix6ZVkw= -github.com/cocoonstack/cocoon v0.6.1 h1:ua6hITulNHD5IVIYXVZt75jGT5tO01HIxqjV0Y6lODA= -github.com/cocoonstack/cocoon v0.6.1/go.mod h1:Y9WCfw84nIS5biVCy3c4cPNvmy70OwP/sdsZv8pu4mQ= +github.com/cocoonstack/cocoon v0.6.2-0.20260902190340-51ff88bcf8f1 h1:X6u71hE3tJ8YqLN4sRtAWAXpcXnVWzCiAhx68yxx1H0= +github.com/cocoonstack/cocoon v0.6.2-0.20260902190340-51ff88bcf8f1/go.mod h1:4Sn0KbxIKPfKGR5NkR4g6L27lNqL7B9Ag0XKLj/Ojxg= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.1 h1:8oU6WsIsU3bpnNZuvHp74a6cE1MJwbj2P7s4/yTUNlA= github.com/containernetworking/plugins v1.9.1/go.mod h1:fj7kS55qg3o/RgS+WGsF3+ZxwIImMPusQZKzBpcSr4c= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= +github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker-credential-helpers v0.9.8 h1:bIREROb7So6PRlq6KTtdS9MPEjC29OQRkFNlvK2OX8Q= +github.com/docker/docker-credential-helpers v0.9.8/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY= @@ -32,15 +40,21 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.7 h1:/vPFuVXDjtFREsVArW+0h1CIl5urnOhzei4X2DMW9IU= +github.com/google/go-containerregistry v0.21.7/go.mod h1:kjSbt7/zMsKLWfnHrIvKvhXHUw91jbe9DNjPPJ32gXE= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -49,6 +63,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/ginkgo/v2 v2.27.4 h1:fcEcQW/A++6aZAZQNUmNjvA9PSOzefMJBerHJ4t8v8Y= github.com/onsi/ginkgo/v2 v2.27.4/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= @@ -64,12 +80,16 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/projecteru2/core v0.1.3 h1:0odzNdtDKRvbMir3xKpqCPJaThHdzfzvou/vGFWquZY= github.com/projecteru2/core v0.1.3/go.mod h1:4WGV1OlU8TY0hra+N8Ua2s8hbX1AeJmcjnj3PcztnI4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.10.1 h1:xi4336Zh11WpU14fXR6I67V3yaTPQYwRx2WEtHbRg4Q= +github.com/sirupsen/logrus v1.10.1/go.mod h1:vsQHnG7xzNsxk3NrwboUiWPnIC3dmbjcGPykD7+tiHk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -141,7 +161,37 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= +modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog= +modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= oras.land/oras-go/v2 v2.6.1 h1:bonOEkjLfp8tt6qXWRRWP6p1F+9octchOf2EqnWB4Zs= oras.land/oras-go/v2 v2.6.1/go.mod h1:dhtFrFOuZuDtAVeZ9FUnaa5zfzplG3ZnFX9/uH1J/Yk= diff --git a/home/home.go b/home/home.go index b9d1b98..c2f418d 100644 --- a/home/home.go +++ b/home/home.go @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" - "github.com/cocoonstack/cocoon/cmd/cliutil" + "github.com/cocoonstack/cocoon/cmd/core" "github.com/cocoonstack/cocoon/images/cloudimg" metajson "github.com/cocoonstack/cocoon/meta/json" ) @@ -42,16 +42,15 @@ func VMDir(cmd *cobra.Command, name string) (string, error) { return filepath.Join(VMsDir(cmd), name), nil } -// OpenStore opens the cloudimg store at the resolved state dir, returning the command context with it. -func OpenStore(cmd *cobra.Command) (context.Context, *cloudimg.CloudImg, error) { - ctx := cliutil.CommandContext(cmd) - metaStore, err := metajson.Open(cloudimg.NewConfig(Dir(cmd), 0).JSONNamespace()) +// OpenStore opens the cloudimg store at the resolved state dir. +func OpenStore(ctx context.Context, cmd *cobra.Command) (*cloudimg.CloudImg, error) { + metaStore, err := metajson.Open(core.ImageJSONNamespace(&cloudimg.NewConfig(Dir(cmd), 0).BaseConfig)) if err != nil { - return ctx, nil, fmt.Errorf("open meta store: %w", err) + return nil, fmt.Errorf("open meta store: %w", err) } s, err := cloudimg.New(ctx, Dir(cmd), 0, metaStore) // 0 = cloudimg's default pull connections if err != nil { - return ctx, nil, fmt.Errorf("init cloudimg store: %w", err) + return nil, fmt.Errorf("init cloudimg store: %w", err) } - return ctx, s, nil + return s, nil } diff --git a/home/home_test.go b/home/home_test.go index ca81133..136196a 100644 --- a/home/home_test.go +++ b/home/home_test.go @@ -9,7 +9,8 @@ import ( func TestOpenStoreEmpty(t *testing.T) { cmd := newTestCmd(t) - ctx, store, err := OpenStore(cmd) + ctx := t.Context() + store, err := OpenStore(ctx, cmd) if err != nil { t.Fatalf("OpenStore: %v", err) } diff --git a/qemu/inject.go b/qemu/inject.go index a4f33a4..f981bca 100644 --- a/qemu/inject.go +++ b/qemu/inject.go @@ -36,7 +36,7 @@ type nbdConnection struct { // fresh context, not the caller's: disconnect must still run when the caller is unwinding after a timeout. func (c *nbdConnection) disconnect() error { - logger := log.WithFunc("qemu.disconnectNBD") + logger := log.WithFunc("qemu.nbdConnection.disconnect") var commandErr error disconnectCtx, disconnectCancel := context.WithTimeout(context.Background(), nbdCommandTimeout) if out, err := exec.CommandContext(disconnectCtx, "qemu-nbd", "--disconnect", c.device).CombinedOutput(); err != nil { @@ -164,6 +164,10 @@ func connectFreeNBD(ctx context.Context, ocPath string) (*nbdConnection, error) var lastErr error pidFile := ocPath + ".nbd.pid" _ = os.Remove(pidFile) + cmdlines, err := readProcCmdlines("/proc") + if err != nil { + return nil, err + } for i := range nbdDeviceCount { nbd := fmt.Sprintf("/dev/nbd%d", i) if _, err := os.Stat(nbd); err != nil { @@ -172,11 +176,7 @@ func connectFreeNBD(ctx context.Context, ocPath string) (*nbdConnection, error) if _, err := os.Stat(fmt.Sprintf("/sys/block/nbd%d/pid", i)); !os.IsNotExist(err) { continue } - referenced, err := processReferencesBlockDevice("/proc", nbd) - if err != nil { - return nil, fmt.Errorf("check whether %s is referenced by a process: %w", nbd, err) - } - if referenced { + if processReferencesBlockDevice(cmdlines, nbd) { continue } out, cerr := exec.CommandContext(ctx, "qemu-nbd", qemuNBDConnectArgs(nbd, pidFile, ocPath)...).CombinedOutput() @@ -205,12 +205,12 @@ func connectFreeNBD(ctx context.Context, ocPath string) (*nbdConnection, error) return nil, errors.New("no free /dev/nbd device (is the nbd module loaded)") } -// a device can stay held by a blocked userspace op after the kernel drops its sysfs pid; reusing it wedges the next attach. -func processReferencesBlockDevice(procRoot, device string) (bool, error) { +func readProcCmdlines(procRoot string) ([]string, error) { entries, err := os.ReadDir(procRoot) if err != nil { - return false, fmt.Errorf("read %s: %w", procRoot, err) + return nil, fmt.Errorf("read %s: %w", procRoot, err) } + var cmdlines []string for _, entry := range entries { if !entry.IsDir() { continue @@ -224,15 +224,23 @@ func processReferencesBlockDevice(procRoot, device string) (bool, error) { if os.IsNotExist(err) { continue } - return false, fmt.Errorf("read %s: %w", cmdlinePath, err) + return nil, fmt.Errorf("read %s: %w", cmdlinePath, err) } - for arg := range strings.SplitSeq(string(cmdline), "\x00") { + cmdlines = append(cmdlines, string(cmdline)) + } + return cmdlines, nil +} + +// a device can stay held by a blocked userspace op after the kernel drops its sysfs pid; reusing it wedges the next attach. +func processReferencesBlockDevice(cmdlines []string, device string) bool { + for _, cmdline := range cmdlines { + for arg := range strings.SplitSeq(cmdline, "\x00") { if nbdArgReferencesDevice(arg, device) { - return true, nil + return true } } } - return false, nil + return false } func nbdArgReferencesDevice(arg, device string) bool { diff --git a/qemu/inject_test.go b/qemu/inject_test.go index ce1aef1..2fcb024 100644 --- a/qemu/inject_test.go +++ b/qemu/inject_test.go @@ -15,7 +15,6 @@ import ( "howett.net/plist" ) -// sampleConfig mirrors OSX-KVM's config.plist so patchPlist round-trips a realistic input. const sampleConfig = ` @@ -134,18 +133,20 @@ func TestProcessReferencesBlockDevice(t *testing.T) { writeCmdline("104", "helper", "/dev/nbd01") for _, device := range []string{"/dev/nbd0", "/dev/nbd1", "/dev/nbd2"} { - busy, err := processReferencesBlockDevice(procRoot, device) + cmdlines, err := readProcCmdlines(procRoot) if err != nil { - t.Fatalf("processReferencesBlockDevice(%s): %v", device, err) + t.Fatalf("readProcCmdlines: %v", err) } + busy := processReferencesBlockDevice(cmdlines, device) if !busy { t.Errorf("processReferencesBlockDevice(%s) = false, want true", device) } } - busy, err := processReferencesBlockDevice(procRoot, "/dev/nbd3") + cmdlines, err := readProcCmdlines(procRoot) if err != nil { t.Fatal(err) } + busy := processReferencesBlockDevice(cmdlines, "/dev/nbd3") if busy { t.Error("unreferenced /dev/nbd3 reported busy") } diff --git a/qemu/launch.go b/qemu/launch.go index 6978ea7..9106450 100644 --- a/qemu/launch.go +++ b/qemu/launch.go @@ -1,4 +1,4 @@ -// Package qemu builds and launches the qemu-system-x86_64 command that boots macOS (Sequoia 15 / Tahoe 26) via OpenCore on an x86 Linux/KVM host, spoofing a GenuineIntel Skylake-Client (TSX off, invariant TSC via CPUID) plus isa-applesmc OSK, OVMF and an OpenCore boot disk; the LongQT OpenCore EFI makes it boot identically on Intel and AMD. +// Package qemu builds and launches the qemu-system-x86_64 command that boots macOS (Sequoia 15 / Tahoe 26) via OpenCore on an x86 Linux/KVM host, spoofing a GenuineIntel Skylake-Client (TSX off, invariant TSC via CPUID) plus isa-applesmc osk, OVMF and an OpenCore boot disk; the LongQT OpenCore EFI makes it boot identically on Intel and AMD. package qemu import ( @@ -7,8 +7,8 @@ import ( ) const ( - // OSK is the Apple SMC key required for macOS guests (public, from OSX-KVM). - OSK = "ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc" + // osk is the Apple SMC key required for macOS guests (public, from OSX-KVM). + osk = "ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc" // macOSCPU is the -cpu for macOS Sequoia/Tahoe; every token is load-bearing — a stripped "Skylake-Client-v4" makes a fresh image's first boot spin forever (regression dacf35c): -hle,-rtm drop TSX (macOS spins on it under nested KVM); +invtsc,vmware-cpuid-freq=on feed the TSC frequency via CPUID so macOS skips self-calibration; vendor=GenuineIntel is mandatory; the +perf flags are affirmed with check= so a base-model bump can't drop them. AMD support comes from the LongQT OpenCore EFI, not the -cpu. macOSCPU = "Skylake-Client,-hle,-rtm,kvm=on,vendor=GenuineIntel,+invtsc,vmware-cpuid-freq=on," + @@ -66,7 +66,7 @@ func (s Spec) Args() []string { "-device", "qemu-xhci,id=xhci", "-device", "usb-kbd,bus=xhci.0", "-device", "usb-tablet,bus=xhci.0", - "-device", "isa-applesmc,osk=" + OSK, + "-device", "isa-applesmc,osk=" + osk, "-drive", "if=pflash,format=raw,readonly=on,file=" + s.OVMFCode, "-drive", "if=pflash,format=" + varsFmt + ",file=" + s.OVMFVars, "-smbios", "type=2", @@ -85,9 +85,6 @@ func (s Spec) Args() []string { // the SATA ports OpenCoreBoot (sata.2) and MacHDD (sata.4) leave free; the count is capped at 4 upstream dataDiskPorts := []int{0, 1, 3, 5} for i, path := range s.DataDisks { - if i >= len(dataDiskPorts) { - break - } id := fmt.Sprintf("DataDisk%d", i) a = append(a, "-drive", fmt.Sprintf("id=%s,%s,file=%s", id, ahciDriveOpts, path), diff --git a/qemu/launch_test.go b/qemu/launch_test.go index a582d95..7721c59 100644 --- a/qemu/launch_test.go +++ b/qemu/launch_test.go @@ -38,11 +38,9 @@ func TestArgsMACAndVNC(t *testing.T) { }) { t.Fatalf("nic mac not on virtio-net-pci: %v", nics) } - // Screen Sharing rejects bare None auth, so a password forces password=on if got := argVals(args, "-vnc"); len(got) != 1 || got[0] != "127.0.0.1:9,password=on" { t.Fatalf("vnc: %v", got) } - // VNC enabled => -k en-us so the guacamole keyboard's shifted keysyms map correctly if got := argVals(args, "-k"); len(got) != 1 || got[0] != "en-us" { t.Errorf("keymap: got %v, want [en-us]", got) } @@ -54,7 +52,6 @@ func TestArgsMACAndVNC(t *testing.T) { } func TestArgsOVMFVarsFormat(t *testing.T) { - // qcow2 NVRAM needs format=qcow2 so qemu-img snapshot can roll it back; a raw .fd stays raw s := Spec{Disk: "/v/d.qcow2", OpenCore: "/v/oc.qcow2", OVMFCode: "/v/c.fd", OVMFVars: "/v/vars.qcow2", CPUs: 1, Memory: "2048", VNCDisp: -1} if !slices.ContainsFunc(s.Args(), func(a string) bool { return strings.Contains(a, "if=pflash,format=qcow2,file=/v/vars.qcow2") }) { t.Fatalf("qcow2 NVRAM not format=qcow2: %v", s.Args()) @@ -78,7 +75,6 @@ func TestArgsDiskTuning(t *testing.T) { } func TestArgsDataDisks(t *testing.T) { - // four disks exercise the full free-port set; ports skip sata.2 (OpenCoreBoot) and sata.4 (MacHDD) files := []string{"/v/data-a.qcow2", "/v/data-b.qcow2", "/v/data-c.qcow2", "/v/data-d.qcow2"} wantPorts := []int{0, 1, 3, 5} s := Spec{Disk: "/v/d.qcow2", OpenCore: "/v/oc.qcow2", OVMFCode: "/v/c.fd", OVMFVars: "/v/v.fd", CPUs: 2, Memory: "4096", VNCDisp: -1, DataDisks: files} @@ -86,7 +82,6 @@ func TestArgsDataDisks(t *testing.T) { devices := argVals(s.Args(), "-device") for i, f := range files { id := fmt.Sprintf("DataDisk%d", i) - // -drive: id/if=none pairing, the file, and the same perf tuning as MacHDD if !slices.ContainsFunc(drives, func(d string) bool { return strings.Contains(d, "id="+id) && strings.Contains(d, "if=none") && strings.Contains(d, "format=qcow2") && strings.Contains(d, "cache=writeback") && @@ -95,13 +90,13 @@ func TestArgsDataDisks(t *testing.T) { }) { t.Errorf("data disk %d drive missing/mistuned: %v", i, drives) } - // -device: ide-hd on the expected free SATA port, bound to the matching drive id + want := fmt.Sprintf("ide-hd,bus=sata.%d,drive=%s", wantPorts[i], id) if !slices.Contains(devices, want) { t.Errorf("data disk %d device: want %q in %v", i, want, devices) } } - // no data disks => no DataDisk drives at all + s.DataDisks = nil if slices.ContainsFunc(argVals(s.Args(), "-drive"), func(d string) bool { return strings.Contains(d, "DataDisk") }) { t.Errorf("no data disks must not emit DataDisk drives") @@ -110,14 +105,14 @@ func TestArgsDataDisks(t *testing.T) { func TestArgsHugepages(t *testing.T) { base := Spec{Disk: "/v/d.qcow2", OpenCore: "/v/oc.qcow2", OVMFCode: "/v/c.fd", OVMFVars: "/v/v.fd", CPUs: 2, Memory: "4096", VNCDisp: -1} - // off by default + if slices.ContainsFunc(base.Args(), func(a string) bool { return strings.Contains(a, "memory-backend") }) { t.Fatalf("hugepages off must not add a memory-backend: %v", base.Args()) } if got := argVals(base.Args(), "-machine"); len(got) != 1 || got[0] != "q35" { t.Fatalf("machine without hugepages: %v", got) } - // on: memfd hugetlb backend sized to the guest RAM, and -machine references it + h := base h.Hugepages = true if !slices.ContainsFunc(h.Args(), func(a string) bool { @@ -139,7 +134,7 @@ func TestArgsCPU(t *testing.T) { if !strings.HasPrefix(cpu[0], "Skylake-Client,") { t.Fatalf("-cpu base must be Skylake-Client (v4 enables TSX -> macOS first-boot spins): %s", cpu[0]) } - // guard every load-bearing token so a "simplification" can't reintroduce regression dacf35c + for _, f := range []string{ "vendor=GenuineIntel", "kvm=on", "-hle", "-rtm", "+invtsc", "vmware-cpuid-freq=on", "+pcid", "+invpcid", "+tsc-deadline", "+rdtscp", @@ -169,7 +164,6 @@ func TestArgsGuestRebootExitsQEMU(t *testing.T) { } } -// argVals returns each token immediately following flag in args. func argVals(args []string, flag string) []string { var out []string for i, a := range args {