Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions cmd/image/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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")
Expand Down
3 changes: 1 addition & 2 deletions cmd/vm/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "":
Expand Down
3 changes: 1 addition & 2 deletions cmd/vm/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions cmd/vm/datadisk.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 1 addition & 2 deletions cmd/vm/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 2 additions & 4 deletions cmd/vm/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, ""
Expand Down
30 changes: 17 additions & 13 deletions cmd/vm/net_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions cmd/vm/net_linux_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 2 additions & 4 deletions cmd/vm/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
18 changes: 6 additions & 12 deletions cmd/vm/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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 <state-dir>/firmware/.
// resolveFirmware returns the OpenCore loader + OVMF code/vars base/template paths: an explicit flag wins, else the shared copy under <state-dir>/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"))
Expand Down
12 changes: 4 additions & 8 deletions cmd/vm/vnc.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,15 @@ 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
}
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))
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<vmid8>-<nic>`, netns `cm-<vmid>`), 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
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading