From 21090fd988a3e285b7a3b8f0275ca80afe06bb56 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 17 Aug 2026 18:16:29 +0800 Subject: [PATCH 1/3] net: scope host TAP/netns families under net_scope "cm" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump cocoon to master (net_scope): bridge.CleanupTAPs now takes the TAP prefix, and config.Config carries NetScope. cocoon-macos builds its network config once (netConf) with NetScope "cm", so its auto-created TAPs (cm-) and CNI netns (cm-) are a separate name family from a co-hosted cocoon's — neither side's GC or cleanup can reclaim the other's live guests. e2e.sh cleanup/leak checks follow the new family (and match the base32 VM IDs the old hex pattern never did). No dual-prefix transition. --- cmd/vm/net_linux.go | 30 +++++++++++++++++------------- docs/networking.md | 4 ++++ go.mod | 2 +- go.sum | 4 ++-- scripts/e2e.sh | 12 ++++++------ 5 files changed, 30 insertions(+), 22 deletions(-) diff --git a/cmd/vm/net_linux.go b/cmd/vm/net_linux.go index e1822da..2910fe9 100644 --- a/cmd/vm/net_linux.go +++ b/cmd/vm/net_linux.go @@ -24,15 +24,23 @@ import ( "github.com/cocoonstack/cocoon/types" ) -// newProvider builds the cocoon network provider: tap/bridge both use the bridge backend (QEMU -// opens the TAP in the host netns, so it must be a host-side bridge port); cni's TAP lives in a netns. -func newProvider(cmd *cobra.Command, r *record) (network.Network, error) { - conf := &config.Config{ +// netScope keys cocoon-macos's host TAP/netns families apart from a co-hosted cocoon's, so neither GC reclaims the other's live guests. +const netScope = "cm" + +// netConf is the cocoon network config: bridge/CNI provisioning shares cocoon's forwarding plane, keyed under our own device family. +func netConf(cmd *cobra.Command) *config.Config { + return &config.Config{ RootDir: home.Dir(cmd), DNS: "8.8.8.8,1.1.1.1", CNIConfDir: flagOr(cmd, "cni-conf-dir", "/etc/cni/net.d"), CNIBinDir: flagOr(cmd, "cni-bin-dir", "/opt/cni/bin"), + NetScope: netScope, } +} + +// newProvider builds the cocoon network provider: tap/bridge both use the bridge backend (QEMU opens the TAP in the host netns, so it must be a host-side bridge port); cni's TAP lives in a netns. +func newProvider(cmd *cobra.Command, r *record) (network.Network, error) { + conf := netConf(cmd) switch r.NetMode { case netCNI: store, err := metajson.Open(cni.NewConfig(conf).JSONNamespace()) @@ -98,12 +106,11 @@ func teardownNet(cmd *cobra.Command, r *record) { } // not gated on newProvider succeeding (rm has no --bridge flag), or an auto-created TAP would leak if r.NetMode == netTAP || r.NetMode == netBridge { - bridge.CleanupTAPs([]string{r.VMID}) + bridge.CleanupTAPs(netConf(cmd).BridgeTAPPrefix(), []string{r.VMID}) } } -// 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. +// 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) { if !r.TapOwned { return @@ -132,8 +139,7 @@ func unquiesceNet(cmd *cobra.Command, r *record) { setTapLink(ctx, r, true) } -// 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. +// 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. func setTapLink(ctx context.Context, r *record, up bool) { if r.Tap == "" || r.Netns != "" { return @@ -153,8 +159,7 @@ func setTapLink(ctx context.Context, r *record, up bool) { } } -// ensureNetnsLoopback brings up lo inside the CNI netns — a fresh netns has it DOWN, so qemu's -// 127.0.0.1 binds would fail with EADDRNOTAVAIL. +// ensureNetnsLoopback brings up lo inside the CNI netns — a fresh netns has it DOWN, so qemu's 127.0.0.1 binds would fail with EADDRNOTAVAIL. func ensureNetnsLoopback(ctx context.Context, r *record) { if r.Netns == "" { return @@ -164,8 +169,7 @@ func ensureNetnsLoopback(ctx context.Context, r *record) { _ = exec.Command("ip", "netns", "exec", ns, "ip", "link", "set", "lo", "up").Run() } -// launchCmd builds the qemu exec, wrapped in `ip netns exec` for CNI so -netdev tap finds the -// in-netns TAP (the fork-safe, cgo-free way to daemonize into a netns). +// launchCmd builds the qemu exec, wrapped in `ip netns exec` for CNI so -netdev tap finds the in-netns TAP (the fork-safe, cgo-free way to daemonize into a netns). func launchCmd(r *record, args []string) *exec.Cmd { if r.Netns != "" { ns := filepath.Base(r.Netns) diff --git a/docs/networking.md b/docs/networking.md index 9979229..979f4d7 100644 --- a/docs/networking.md +++ b/docs/networking.md @@ -14,6 +14,10 @@ Hypervisor / Firecracker VMs on the node, so the guest can DHCP a **real LAN IP* network. The guest NIC MAC stays equal to the SMBIOS ROM. Auto-create (`bridge`/`cni`) is Linux-only (needs `CAP_NET_ADMIN`); `user` and a pre-created `--tap` work everywhere. +Auto-created devices carry cocoon-macos's own host name family (`net_scope` `cm`: TAPs +`cm-`, netns `cm-`), so a cocoon daemon's GC on the same node never reads a live +macOS guest's TAP as an orphan (see cocoon's `net_scope` in its networking docs). + ### `--net cni` and TC redirect CNI runs QEMU inside a per-VM network namespace. cocoon's CNI wires the netns veth to the QEMU TAP diff --git a/go.mod b/go.mod index fac1099..6d84de0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/cocoonstack/cocoon-macos go 1.26.5 require ( - github.com/cocoonstack/cocoon v0.5.7 + github.com/cocoonstack/cocoon v0.5.10-0.20260817090435-cde2318b40d9 github.com/docker/go-units v0.5.0 github.com/opencontainers/image-spec v1.1.1 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c diff --git a/go.sum b/go.sum index 270f804..49079ac 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZe github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cocoonstack/cocoon v0.5.7 h1:iw08rTjHa63JlT/9sf/rG7acEbevleEQAeD03MrwQYc= -github.com/cocoonstack/cocoon v0.5.7/go.mod h1:faTopIRmQTMfqbkxkRngRlvxEmi+4Nj1Xr84VrIoSVs= +github.com/cocoonstack/cocoon v0.5.10-0.20260817090435-cde2318b40d9 h1:TRC8rx4GFOMrIxxARayChTwj5PxrvF9rVEqfuXjaezk= +github.com/cocoonstack/cocoon v0.5.10-0.20260817090435-cde2318b40d9/go.mod h1:faTopIRmQTMfqbkxkRngRlvxEmi+4Nj1Xr84VrIoSVs= 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.0 h1:Mg3SXBdRGkdXyFC4lcwr6u2ZB2SDeL6LC3U+QrEANuQ= diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 8614c08..1eef9bc 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -7,7 +7,7 @@ # [REAL] boots ghcr tahoe:26, passes the OpenCore picker over the HMP monitor, asserts SSH. # Gated behind --real (needs /dev/kvm + the ~15GB image + OVMF/OpenCore loaders). # -# Idempotent: cleans state-dir + test bridge + leftover bt*/netns at start AND end. +# Idempotent: cleans state-dir + test bridge + leftover cm* TAPs/netns at start AND end. # # Usage: # sudo ./cm-smoke.sh # [DUMMY] tier only @@ -89,12 +89,12 @@ clean_state() { fi # 2) belt-and-suspenders: kill any qemu stand-ins we spawned pkill -f "$QEMU_STUB" 2>/dev/null || true - # 3) nuke leftover cocoon bridge TAPs (bt<8hex>-N) + any test netns, in case rm raced/failed + # 3) nuke leftover cocoon-macos bridge TAPs (cm-N, net_scope cm) + our netns, in case rm raced/failed if command -v ip >/dev/null 2>&1; then - ip -o link show 2>/dev/null | grep -oE 'bt[0-9a-f]{1,8}-[0-9]+' | sort -u | while read -r t; do + ip -o link show 2>/dev/null | grep -oE 'cm[A-Z2-7]{8}-[0-9]+' | sort -u | while read -r t; do ip link del "$t" 2>/dev/null || true done - ip -o netns list 2>/dev/null | awk '{print $1}' | grep -E '^cni-|cocoon|cmsmoke' | while read -r ns; do + ip -o netns list 2>/dev/null | awk '{print $1}' | grep -E '^cm-|cmsmoke' | while read -r ns; do ip netns del "$ns" 2>/dev/null || true done ip link show "$CM_BRIDGE" >/dev/null 2>&1 && { ip link set "$CM_BRIDGE" down 2>/dev/null; ip link del "$CM_BRIDGE" 2>/dev/null; } @@ -259,8 +259,8 @@ PY post_conditions_dummy() { log "---- [DUMMY] post-conditions ----" - leaks=$(ip -o link show 2>/dev/null | grep -oE 'bt[0-9a-f]{1,8}-[0-9]+' | sort -u || true) - if [ -z "$leaks" ]; then pass "[POST] no leaked bt* TAPs"; else fail "[POST] leaked bt* TAPs" "$leaks"; fi + leaks=$(ip -o link show 2>/dev/null | grep -oE 'cm[A-Z2-7]{8}-[0-9]+' | sort -u || true) + if [ -z "$leaks" ]; then pass "[POST] no leaked cm* TAPs"; else fail "[POST] leaked cm* TAPs" "$leaks"; fi if ! pgrep -f "$QEMU_STUB" >/dev/null 2>&1; then pass "[POST] no leaked stand-in procs"; else fail "[POST] leaked procs" "$(pgrep -fa "$QEMU_STUB")"; fi bad=0 for d in "$CM_HOME"/vms/*/disk.qcow2; do From 7c1a86ead9e4f0e0ba1b62e8006f31970f6164b5 Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 17 Aug 2026 18:16:29 +0800 Subject: [PATCH 2/3] review: join wrapped comments to one line --- cmd/image/oci.go | 12 ++++-------- cmd/vm/clone.go | 3 +-- cmd/vm/commands.go | 3 +-- cmd/vm/datadisk.go | 9 +++------ cmd/vm/handler.go | 3 +-- cmd/vm/lifecycle.go | 6 ++---- cmd/vm/snapshot.go | 6 ++---- cmd/vm/utils.go | 18 ++++++------------ cmd/vm/vnc.go | 12 ++++-------- qemu/inject.go | 15 +++++---------- qemu/launch.go | 19 ++++--------------- qemu/smbios.go | 3 +-- qemu/snapshot.go | 3 +-- 13 files changed, 35 insertions(+), 77 deletions(-) diff --git a/cmd/image/oci.go b/cmd/image/oci.go index ae7a1fd..9a731a0 100644 --- a/cmd/image/oci.go +++ b/cmd/image/oci.go @@ -23,8 +23,7 @@ import ( "github.com/cocoonstack/cocoon/utils" ) -// pullConns is the parallel HTTP Range connection count; ghcr throttles a single stream to a -// fraction of the link. +// pullConns is the parallel HTTP Range connection count; ghcr throttles a single stream to a fraction of the link. const pullConns = 8 // pullOCIBlob downloads ref's qcow2 layer to dest and verifies its sha256 digest. @@ -87,8 +86,7 @@ func resolveQcow2Layer(ctx context.Context, repo *remote.Repository, ref string) return layer, nil } -// rangeSupported probes whether the blob endpoint honors Range (ghcr's presigned redirect does; -// a registry answering 200 does not). +// rangeSupported probes whether the blob endpoint honors Range (ghcr's presigned redirect does; a registry answering 200 does not). func rangeSupported(ctx context.Context, client *auth.Client, url string) bool { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { @@ -153,8 +151,7 @@ func verifyDigest(path, want string) error { return nil } -// dockerCredential resolves credentials from the user's docker config; a missing config yields an -// empty store, so anonymous public pulls still work. +// dockerCredential resolves credentials from the user's docker config; a missing config yields an empty store, so anonymous public pulls still work. func dockerCredential() auth.CredentialFunc { store, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) if err != nil { @@ -163,8 +160,7 @@ func dockerCredential() auth.CredentialFunc { return credentials.Credential(store) } -// pickQcow2Layer prefers the layer whose title annotation ends in .qcow2 (what `oras push` -// writes), else the largest. +// pickQcow2Layer prefers the layer whose title annotation ends in .qcow2 (what `oras push` writes), else the largest. func pickQcow2Layer(layers []ocispec.Descriptor) (ocispec.Descriptor, error) { if len(layers) == 0 { return ocispec.Descriptor{}, fmt.Errorf("manifest has no layers") diff --git a/cmd/vm/clone.go b/cmd/vm/clone.go index 36c2b3d..1bccae6 100644 --- a/cmd/vm/clone.go +++ b/cmd/vm/clone.go @@ -96,8 +96,7 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { return nil } -// cloneOpenCoreBase returns the immutable base a clone overlays — never SRC's per-VM overlay, -// which would break on `vm rm SRC`. +// cloneOpenCoreBase returns the immutable base a clone overlays — never SRC's per-VM overlay, which would break on `vm rm SRC`. func cloneOpenCoreBase(cmd *cobra.Command, src *record) (string, error) { switch { case src.OpenCoreBase != "": diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 46f8635..62e8d58 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -6,8 +6,7 @@ import ( "github.com/cocoonstack/cocoon/cmd/cliutil" ) -// Actions mirrors cocoon's cmd/vm Actions, re-declared rather than imported because cocoon's -// commands.go drags in the Linux-only CH/netlink backend. +// Actions mirrors cocoon's cmd/vm Actions, re-declared rather than imported because cocoon's commands.go drags in the Linux-only CH/netlink backend. type Actions interface { Create(cmd *cobra.Command, args []string) error Run(cmd *cobra.Command, args []string) error diff --git a/cmd/vm/datadisk.go b/cmd/vm/datadisk.go index 5c6f0f4..5327c9d 100644 --- a/cmd/vm/datadisk.go +++ b/cmd/vm/datadisk.go @@ -14,17 +14,14 @@ import ( ) const ( - // minDataDiskSize mirrors cocoon's hypervisor.MinDataDiskSize, kept local because that package - // isn't dependency-light. + // minDataDiskSize mirrors cocoon's hypervisor.MinDataDiskSize, kept local because that package isn't dependency-light. minDataDiskSize int64 = 16 << 20 - // maxDataDisks: macOS has no virtio-blk, so disks ride ich9-ahci's 6 SATA ports; - // OpenCoreBoot=sata.2 and MacHDD=sata.4 leave exactly four free. + // maxDataDisks: macOS has no virtio-blk, so disks ride ich9-ahci's 6 SATA ports; OpenCoreBoot=sata.2 and MacHDD=sata.4 leave exactly four free. maxDataDisks = 4 ) -// parseDataDisks parses --data-disk args, auto-naming unnamed ones dataN; reserved names -// (a clone's copied disks) count against both the duplicate check and the AHCI cap. +// parseDataDisks parses --data-disk args, auto-naming unnamed ones dataN; reserved names (a clone's copied disks) count against both the duplicate check and the AHCI cap. func parseDataDisks(raw, reserved []string) ([]types.DataDiskSpec, error) { used := make(map[string]bool, len(reserved)) for _, n := range reserved { diff --git a/cmd/vm/handler.go b/cmd/vm/handler.go index edd49d2..2453e52 100644 --- a/cmd/vm/handler.go +++ b/cmd/vm/handler.go @@ -19,8 +19,7 @@ const ( var _ Actions = (*Handler)(nil) -// Handler implements the vm Actions: per-VM CoW overlays on a golden macOS qcow2, booted by -// qemu-system-x86_64 on an x86 Linux/KVM host. +// Handler implements the vm Actions: per-VM CoW overlays on a golden macOS qcow2, booted by qemu-system-x86_64 on an x86 Linux/KVM host. type Handler struct{} // NewHandler returns a Handler ready to serve the vm subcommands. diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index 63f1317..f905beb 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -31,8 +31,7 @@ func (h *Handler) Run(cmd *cobra.Command, args []string) error { return err } if err := h.launch(cmd, home.VMDir(cmd, r.Name), r); err != nil { - // atomic create+boot: remove everything on failure or the leftover record bricks retries; - // start must NOT do this — its network is persisted + // atomic create+boot: remove everything on failure or the leftover record bricks retries; start must NOT do this — its network is persisted teardownNet(cmd, r) _ = os.RemoveAll(home.VMDir(cmd, r.Name)) return err @@ -222,8 +221,7 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error { return saveRec(dir, r) } -// 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. +// 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 { r.OpenCore, r.OpenCoreBase = ocBase, "" diff --git a/cmd/vm/snapshot.go b/cmd/vm/snapshot.go index f64b903..1adf21c 100644 --- a/cmd/vm/snapshot.go +++ b/cmd/vm/snapshot.go @@ -91,8 +91,7 @@ func (h *Handler) Restore(cmd *cobra.Command, args []string) error { return nil } -// snapshotAllOrNothing tags every image or rolls back the ones already tagged, so a partial -// failure never leaves an untracked snapshot point on some disks. +// snapshotAllOrNothing tags every image or rolls back the ones already tagged, so a partial failure never leaves an untracked snapshot point on some disks. func snapshotAllOrNothing(ctx context.Context, imgs []string, tag string) error { var created []string for _, img := range imgs { @@ -109,8 +108,7 @@ func snapshotAllOrNothing(ctx context.Context, imgs []string, tag string) error return nil } -// imagesToSnapshot includes OVMF_VARS only when qcow2 — a raw .fd can't hold internal snapshots, -// so with raw NVRAM only guest disk state rolls back. +// imagesToSnapshot includes OVMF_VARS only when qcow2 — a raw .fd can't hold internal snapshots, so with raw NVRAM only guest disk state rolls back. func imagesToSnapshot(r *record) []string { imgs := []string{r.Disk} if qemu.IsQcow2NVRAM(r.OVMFVars) { diff --git a/cmd/vm/utils.go b/cmd/vm/utils.go index f4acac7..42bd11b 100644 --- a/cmd/vm/utils.go +++ b/cmd/vm/utils.go @@ -55,8 +55,7 @@ func bakeOverlay(ctx context.Context, base, dst string) error { return nil } -// scaffoldVM lays down a new VM dir, disk overlay, and OVMF_VARS copy; it refuses an existing -// record — a second create/clone under the same name would truncate the live overlay. +// scaffoldVM lays down a new VM dir, disk overlay, and OVMF_VARS copy; it refuses an existing record — a second create/clone under the same name would truncate the live overlay. func scaffoldVM(cmd *cobra.Command, name, image, varsSrc, varsName string) (dir, overlay, ovmfVars, digest string, err error) { dir = home.VMDir(cmd, name) if _, statErr := os.Stat(filepath.Join(dir, "vm.json")); statErr == nil { @@ -80,8 +79,7 @@ func scaffoldVM(cmd *cobra.Command, name, image, varsSrc, varsName string) (dir, return dir, overlay, ovmfVars, digest, nil } -// 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. +// 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 { case "", netUser: @@ -94,8 +92,7 @@ func prepareNet(cmd *cobra.Command, r *record) (tap, netns, mac string, err erro return provisionNet(cmd, r) } -// applyNet provisions networking and records it; a TAP is "owned" (torn down on rm) only -// when auto-created, never when the user passed --tap. +// applyNet provisions networking and records it; a TAP is "owned" (torn down on rm) only when auto-created, never when the user passed --tap. func applyNet(cmd *cobra.Command, r *record) error { userTap := r.Tap netTap, netns, mac, err := prepareNet(cmd, r) @@ -135,8 +132,7 @@ func hostIsAMD() bool { return err == nil && strings.Contains(string(b), "AuthenticAMD") } -// resolveBase returns the immutable base qcow2 (+ digest): a direct filesystem path, else an -// image ref resolved through cocoon's cloudimg store. +// 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) { if _, err := os.Stat(image); err == nil { return image, "", nil @@ -157,8 +153,7 @@ func resolveBase(cmd *cobra.Command, image, name string) (string, string, error) return sc[0][0].Path, vm.ImageDigest, nil } -// ensureCloudimgFirmware writes a placeholder CLOUDHV.fd purely to satisfy cloudimg.Config's -// firmware validation — cocoon-macos boots via OVMF and never reads it. +// ensureCloudimgFirmware writes a placeholder CLOUDHV.fd purely to satisfy cloudimg.Config's firmware validation — cocoon-macos boots via OVMF and never reads it. func ensureCloudimgFirmware(cmd *cobra.Command) { fw := images.FirmwarePath(home.Dir(cmd)) if utils.ValidFile(fw) { @@ -169,8 +164,7 @@ func ensureCloudimgFirmware(cmd *cobra.Command) { } } -// resolveFirmware returns the OpenCore loader + OVMF code/vars base/template paths: an explicit -// flag wins, else the shared copy under /firmware/. +// resolveFirmware returns the OpenCore loader + OVMF code/vars base/template paths: an explicit flag wins, else the shared copy under /firmware/. func resolveFirmware(cmd *cobra.Command) (opencore, code, vars string, err error) { fw := home.FirmwareDir(cmd) opencore = flagOr(cmd, "opencore", filepath.Join(fw, "OpenCore.qcow2")) diff --git a/cmd/vm/vnc.go b/cmd/vm/vnc.go index 8a1a56c..4588242 100644 --- a/cmd/vm/vnc.go +++ b/cmd/vm/vnc.go @@ -29,8 +29,7 @@ const ( var errCNIVNCPassRequired = errors.New("--vnc with --net cni serves VNC on a host port reachable off-box; --vnc-password is required") -// 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. +// 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 { if isCNI && vncDisp >= 0 && vncPass == "" { return errCNIVNCPassRequired @@ -38,8 +37,7 @@ 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 rejects control characters (a newline would inject a second HMP command) and enforces QEMU's 8-char VNC limit. func validateVNCPassword(pw string) error { if len(pw) > 8 { return fmt.Errorf("--vnc-password must be at most 8 characters, got %d", len(pw)) @@ -58,8 +56,7 @@ func vncProxyCommand() *cobra.Command { } } -// startVNCProxy re-execs this binary as the detached proxy in the HOST netns, so its TCP listener -// is reachable while qemu's VNC stays inside the CNI netns. Idempotent. +// startVNCProxy re-execs this binary as the detached proxy in the HOST netns, so its TCP listener is reachable while qemu's VNC stays inside the CNI netns. Idempotent. func startVNCProxy(ctx context.Context, dir string, disp int) error { stopVNCProxy(ctx, dir) // a stale proxy would hold the port and shadow the new one sock := filepath.Join(dir, vncSockName) @@ -97,8 +94,7 @@ func startVNCProxy(ctx context.Context, dir string, disp int) error { return nil } -// 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. +// 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 { diff --git a/qemu/inject.go b/qemu/inject.go index 05ab3f9..e1c9e2b 100644 --- a/qemu/inject.go +++ b/qemu/inject.go @@ -16,8 +16,7 @@ import ( "github.com/cocoonstack/cocoon/utils" ) -// InjectConfig mounts the OpenCore qcow2 via qemu-nbd (the only way to edit a FAT partition -// inside a qcow2; needs root + the nbd module) and patches config.plist with a per-VM identity. +// InjectConfig mounts the OpenCore qcow2 via qemu-nbd (the only way to edit a FAT partition inside a qcow2; needs root + the nbd module) and patches config.plist with a per-VM identity. func InjectConfig(ctx context.Context, ocPath string, sm *SMBIOS) error { _ = exec.CommandContext(ctx, "modprobe", "nbd", "max_part=8").Run() nbd, err := connectFreeNBD(ctx, ocPath) @@ -56,8 +55,7 @@ func waitForPart(ctx context.Context, nbd string) { }) } -// disconnectNBD waits out qemu-nbd's asynchronous release, or the qemu launch races in and fails -// with "Failed to get shared write lock". +// disconnectNBD waits out qemu-nbd's asynchronous release, or the qemu launch races in and fails with "Failed to get shared write lock". func disconnectNBD(ctx context.Context, nbd, ocPath string) { logger := log.WithFunc("qemu.disconnectNBD") _ = exec.Command("qemu-nbd", "--disconnect", nbd).Run() @@ -68,15 +66,13 @@ func disconnectNBD(ctx context.Context, nbd, ocPath string) { } } -// isFileHeld matches the daemonized qemu-nbd server's cmdline — cheaper than scanning fd tables, -// and that server is the only holder to wait out. +// isFileHeld matches the daemonized qemu-nbd server's cmdline — cheaper than scanning fd tables, and that server is the only holder to wait out. func isFileHeld(ocPath string) bool { pids, err := utils.FindVMMByCmdline("qemu-nbd", ocPath) return err == nil && len(pids) > 0 } -// connectFreeNBD claims a device by connecting: the connect itself is the exclusive operation, -// so a race with another VM create just advances to the next candidate. +// connectFreeNBD claims a device by connecting: the connect itself is the exclusive operation, so a race with another VM create just advances to the next candidate. func connectFreeNBD(ctx context.Context, ocPath string) (string, error) { var lastErr error for i := range 16 { @@ -125,8 +121,7 @@ func patchPlist(path string, sm *SMBIOS) error { g["ROM"] = rom g["SpoofVendor"] = true } - // ShowPicker=false: a visible picker can't be driven headlessly — OpenCanopy cancels its - // Timeout on stray USB-enumeration input and waits forever + // ShowPicker=false: a visible picker can't be driven headlessly — OpenCanopy cancels its Timeout on stray USB-enumeration input and waits forever boot := ensureSubMap(ensureSubMap(cfg, "Misc"), "Boot") boot["ShowPicker"] = false boot["HideAuxiliary"] = true diff --git a/qemu/launch.go b/qemu/launch.go index 6ba57fd..524bf7d 100644 --- a/qemu/launch.go +++ b/qemu/launch.go @@ -1,7 +1,4 @@ -// Package qemu builds and launches the qemu-system-x86_64 command for booting -// macOS (Sequoia 15 / Tahoe 26) via OpenCore on an x86 Linux/KVM host. The argument vector spoofs a -// GenuineIntel Skylake-Client (TSX off, invariant-TSC frequency fed via CPUID) + isa-applesmc OSK + -// OVMF + an OpenCore boot disk; with the LongQT OpenCore EFI it boots 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 ( @@ -13,19 +10,12 @@ const ( // 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 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," + "+ssse3,+sse4.2,+popcnt,+avx,+aes,+xsave,+xsaveopt," + "+pcid,+invpcid,+tsc-deadline,+rdtscp,+xsavec,check" - // ahciDriveOpts tunes every writable disk (macOS has no virtio-blk): io_uring beats the threads - // aio backend; cache=writeback masks qcow2 + cloud-disk latency (cache=none would hit the - // network disk on every I/O); discard/detect-zeroes reclaim freed clusters (slim depends on it). + // ahciDriveOpts tunes every writable disk (macOS has no virtio-blk): io_uring beats the threads aio backend; cache=writeback masks qcow2 + cloud-disk latency (cache=none would hit the network disk on every I/O); discard/detect-zeroes reclaim freed clusters (slim depends on it). ahciDriveOpts = "if=none,format=qcow2,cache=writeback,aio=io_uring,discard=unmap,detect-zeroes=unmap" ) @@ -135,8 +125,7 @@ func (s Spec) Args() []string { return a } -// IsQcow2NVRAM is the single source of the qcow2-NVRAM rule: the pflash -drive format and NVRAM's -// participation in internal snapshots must stay in lockstep. +// IsQcow2NVRAM is the single source of the qcow2-NVRAM rule: the pflash -drive format and NVRAM's participation in internal snapshots must stay in lockstep. func IsQcow2NVRAM(path string) bool { return strings.HasSuffix(path, ".qcow2") } diff --git a/qemu/smbios.go b/qemu/smbios.go index de5a627..591f90c 100644 --- a/qemu/smbios.go +++ b/qemu/smbios.go @@ -11,8 +11,7 @@ const ( serialAlphabet = "ABCDEFGHIJKLMNPQRSTUVWXYZ0123456789" // Apple omits O (0/O ambiguity) ) -// SMBIOS is a per-VM Apple machine identity injected into OpenCore PlatformInfo/Generic; -// format-valid and unique but NOT Apple-validated. +// SMBIOS is a per-VM Apple machine identity injected into OpenCore PlatformInfo/Generic; format-valid and unique but NOT Apple-validated. type SMBIOS struct { Model string `json:"model"` // SystemProductName (fixed; proven to boot Tahoe) Serial string `json:"serial"` // SystemSerialNumber diff --git a/qemu/snapshot.go b/qemu/snapshot.go index 7a75b1a..e20705d 100644 --- a/qemu/snapshot.go +++ b/qemu/snapshot.go @@ -10,8 +10,7 @@ import ( "github.com/cocoonstack/cocoon/utils" ) -// SnapCreate records an internal snapshot tag in the qcow2 img. Offline only: +invtsc blocks live -// savevm, and qemu-img snapshot on a live image corrupts it — the VM must be stopped. +// SnapCreate records an internal snapshot tag in the qcow2 img. Offline only: +invtsc blocks live savevm, and qemu-img snapshot on a live image corrupts it — the VM must be stopped. func SnapCreate(ctx context.Context, img, tag string) error { return utils.RunQemuImg(ctx, "snapshot", "-c", tag, img) } From 7dd7fe4e5ad3edc8e170eda752d826c103d3c44d Mon Sep 17 00:00:00 2001 From: CMGS Date: Mon, 17 Aug 2026 18:29:21 +0800 Subject: [PATCH 3/3] test: lock network scope prefixes --- cmd/vm/net_linux_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 cmd/vm/net_linux_test.go diff --git a/cmd/vm/net_linux_test.go b/cmd/vm/net_linux_test.go new file mode 100644 index 0000000..fbe28dd --- /dev/null +++ b/cmd/vm/net_linux_test.go @@ -0,0 +1,22 @@ +//go:build linux + +package vm + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestNetConfScope(t *testing.T) { + conf := netConf(&cobra.Command{}) + if got, want := conf.NetScope, "cm"; got != want { + t.Errorf("NetScope = %q, want %q", got, want) + } + if got, want := conf.BridgeTAPPrefix(), "cm"; got != want { + t.Errorf("BridgeTAPPrefix() = %q, want %q", got, want) + } + if got, want := conf.NetnsPrefix(), "cm-"; got != want { + t.Errorf("NetnsPrefix() = %q, want %q", got, want) + } +}