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
7 changes: 1 addition & 6 deletions cmd/image/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@ import (
)

func Command() *cobra.Command {
imageCmd := &cobra.Command{
Use: "image",
Short: "Manage macOS disk images (reuses cocoon's cloudimg store)",
Args: cobra.NoArgs,
RunE: cliutil.ShowHelp,
}
imageCmd := cliutil.GroupCommand("image", "Manage macOS disk images (reuses cocoon's cloudimg store)")

pull := &cobra.Command{
Use: "pull REF",
Expand Down
2 changes: 1 addition & 1 deletion cmd/image/oci.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func pullOCIBlob(ctx context.Context, ref, dest string) error {
}
blobURL := fmt.Sprintf("https://%s/v2/%s/blobs/%s", repo.Reference.Host(), repo.Reference.Repository, layer.Digest)

f, err := os.Create(dest) //nolint:gosec // dest is an internal temp path (os.CreateTemp), not user input
f, err := os.Create(dest)
if err != nil {
return err
}
Expand Down
7 changes: 1 addition & 6 deletions cmd/vm/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@ import (
)

func Command() *cobra.Command {
vmCmd := &cobra.Command{
Use: "vm",
Short: "Manage macOS VMs",
Args: cobra.NoArgs,
RunE: cliutil.ShowHelp,
}
vmCmd := cliutil.GroupCommand("vm", "Manage macOS VMs")

createCmd := &cobra.Command{
Use: "create [flags] IMAGE",
Expand Down
28 changes: 5 additions & 23 deletions cmd/vm/gc.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func GC(cmd *cobra.Command, _ []string) error {
return sweep(cliutil.CommandContext(cmd), cmd, registerNetGC)
}

// sweep takes the provisioning lock exclusively and every VM's lock, so a create, clone or rm in flight is never read as residue; a busy lock refuses the run. The cm-family collectors are host-global, so they run only for a root that has owned host devices: a VM dir seen under the lock, or the marker every path that provisions or removes a VM dir persists first. A root without either still sweeps its own temp files.
// sweep holds the provisioning lock exclusively and every VM lock, so an in-flight create, clone or rm is never read as residue.
func sweep(ctx context.Context, cmd *cobra.Command, registerNet func(*gc.Orchestrator, *cobra.Command, network.VMInUse) error) error {
if _, err := os.Stat(home.Dir(cmd)); err != nil {
return fmt.Errorf("no state root at %s: check --state-dir or $COCOON_MACOS_HOME: %w", home.Dir(cmd), err)
Expand Down Expand Up @@ -80,13 +80,13 @@ func sweep(ctx context.Context, cmd *cobra.Command, registerNet func(*gc.Orchest
gc.Register(o, vmGCModule(home.Dir(cmd), dirs))
if len(dirs) == 0 && !utils.FileExists(filepath.Join(home.VMsDir(cmd), ".locks", provisionedMarker)) {
log.WithFunc("cmd.vm.sweep").Warnf(ctx, "no VM dir and no provisioning marker under %s: leaving the host's netns and TAPs alone (check --state-dir or $COCOON_MACOS_HOME; a root from before this build regains them at its next create)", home.Dir(cmd))
} else if err := registerNet(o, cmd, vmInUse(dirs)); err != nil {
} else if err := registerNet(o, cmd, func(context.Context, string) (bool, error) { return false, nil }); err != nil {
return err
}
return o.Run(ctx)
}

// withProvisionLock shares the gc lock for the span in which a create or clone owns host devices no record names yet; the file is persistent so a waiter never ends up on an unlinked inode.
// withProvisionLock shares the gc lock for the span in which a create or clone owns host devices no record names yet.
func withProvisionLock(ctx context.Context, cmd *cobra.Command, fn func() error) error {
gl, err := openGCLock(cmd)
if err != nil {
Expand All @@ -99,13 +99,13 @@ func withProvisionLock(ctx context.Context, cmd *cobra.Command, fn func() error)
return fn()
}

// markProvisioned records that the state root owning vmsDir has held a VM: applyNet writes it right before the first host device, rm and the incomplete-dir reset before they remove a VM dir, so the evidence outlives the dir while a create that failed before provisioning under a mistyped root leaves nothing behind.
// markProvisioned records that the state root owning vmsDir has held a VM; the marker outlives the last VM dir.
func markProvisioned(vmsDir string) error {
path := filepath.Join(vmsDir, ".locks", provisionedMarker)
if err := utils.EnsureDirs(filepath.Dir(path)); err != nil {
return fmt.Errorf("create vm lock dir: %w", err)
}
marker, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600) //nolint:gosec // under the state root's own lock dir
marker, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("mark state root provisioned: %w", err)
}
Expand Down Expand Up @@ -165,24 +165,6 @@ func tryLockVMDirs(ctx context.Context, dirs []string) (func(), error) {
return unlock, nil
}

func vmInUse(dirs []string) network.VMInUse {
return func(_ context.Context, vmID string) (bool, error) {
for _, dir := range dirs {
r, err := loadRec(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return false, fmt.Errorf("read vm %s for gc: %w", filepath.Base(dir), err)
}
if r.VMID == vmID {
return isRunning(r), nil
}
}
return false, nil
}
}

func vmGCModule(stateDir string, dirs []string) gc.Module[vmGCSnapshot] {
return gc.Module[vmGCSnapshot]{
Name: "vm",
Expand Down
2 changes: 1 addition & 1 deletion cmd/vm/gc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ func TestGCRefusesABusyVM(t *testing.T) {
if err := held.Lock(t.Context()); err != nil {
t.Fatal(err)
}
defer held.Unlock(t.Context()) //nolint:errcheck
defer held.Unlock(t.Context())

err := sweep(t.Context(), cmd, noNetGC)
if err == nil || !strings.Contains(err.Error(), "busy") {
Expand Down
2 changes: 1 addition & 1 deletion cmd/vm/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type record struct {
NetMode string `json:"net_mode,omitempty"`
Hugepages bool `json:"hugepages,omitempty"`
ExitOnReboot bool `json:"exit_on_reboot,omitempty"`
DataDisks []string `json:"data_disks,omitempty"` // created data-disk qcow2 paths, attached on AHCI ports 0,1,3,5
DataDisks []string `json:"data_disks,omitempty"`

Disk string `json:"disk"`
OpenCore string `json:"opencore"`
Expand Down
2 changes: 1 addition & 1 deletion cmd/vm/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func Start(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
// an op that held the lock (export, a racing run) may have restarted qemu; adopt it and only repair a dead vnc proxy
// an op that held the lock (a racing run) may have restarted qemu; adopt it and only repair a dead vnc proxy
running, err := reconcileRunningQEMU(dir, r)
if err != nil {
return err
Expand Down
4 changes: 0 additions & 4 deletions cmd/vm/net_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package vm
import (
"cmp"
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
Expand Down Expand Up @@ -77,9 +76,6 @@ 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)
Expand Down
2 changes: 1 addition & 1 deletion cmd/vm/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ func powerDown(ctx context.Context, monSock string, r *record, grace time.Durati
return false
}
if err := utils.WaitFor(ctx, grace, 200*time.Millisecond, func() (bool, error) { return !isRunning(r), nil }); err != nil {
logger.Infof(ctx, "guest %s did not halt within %s; signaling qemu", r.Name, grace)
logger.Warnf(ctx, "guest %s did not halt within %s; signaling qemu", r.Name, grace)
}
return true
}
Expand Down
8 changes: 4 additions & 4 deletions cmd/vm/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,17 @@ func TestStopInstanceAsksTheGuestToPowerDownFirst(t *testing.T) {
if err != nil {
t.Fatal(err)
}
defer ln.Close() //nolint:errcheck
defer ln.Close()
got := make(chan string, 1)
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close() //nolint:errcheck
fmt.Fprint(conn, hmpPrompt) //nolint:errcheck
defer conn.Close()
fmt.Fprint(conn, hmpPrompt)
line, _ := bufio.NewReader(conn).ReadString('\n')
fmt.Fprint(conn, " "+line+hmpPrompt) //nolint:errcheck
fmt.Fprint(conn, " "+line+hmpPrompt)
got <- strings.TrimSpace(line)
}()
disk := filepath.Join(dir, "disk.qcow2")
Expand Down
5 changes: 2 additions & 3 deletions cmd/vm/vnc.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import (
"github.com/cocoonstack/cocoon/utils"
)

// The proxy fronts a CNI VM's netns-local VNC unix socket on a host TCP port (see qemu.Spec.VNCSock).
const (
vncSockName = "vnc.sock"
vncProxyPID = "vnc-proxy.pid"
Expand Down Expand Up @@ -53,8 +52,8 @@ func validateVNCPassword(pw string) error {
if len(pw) > 8 {
return fmt.Errorf("--vnc-password must be at most 8 bytes, got %d", len(pw))
}
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")
if strings.ContainsFunc(pw, func(r rune) bool { return unicode.IsControl(r) || unicode.IsSpace(r) || r == '"' }) {
return errors.New("--vnc-password must not contain whitespace, control characters or double quotes")
}
return nil
}
Expand Down
1 change: 1 addition & 0 deletions cmd/vm/vnc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func TestValidateVNCPassword(t *testing.T) {
{"newline injection rejected", "x\nquit", true},
{"carriage return rejected", "x\rset", true},
{"tab rejected", "a\tb", true},
{"leading quote rejected", "\"abcdef\"", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,9 @@ snapshot and prevents new orphan collection; a read error is not evidence that t

## What `vm run` does

1. `qemu-img create -b <golden> overlay.qcow2` — instant copy-on-write clone of the golden image.
1. `qemu-img create -b <golden> disk.qcow2` — instant copy-on-write clone of the golden image.
2. Copy a per-VM `OVMF_VARS`.
3. With `--random-smbios`, copy OpenCore per-VM and inject a generated identity into its
3. With `--random-smbios`, bake a per-VM OpenCore overlay and inject a generated identity into its
`config.plist` `PlatformInfo/Generic` via a `qemu-nbd` mount — model stays `iMac19,1` (proven to
boot Tahoe), only serial/MLB/UUID/ROM are randomized. The identity is recorded and shown by
`vm inspect`.
Expand Down
9 changes: 6 additions & 3 deletions docs/networking.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ stops storming softirqs, and `vm start` (or the relaunch) brings it back up. `vm
`create`) deletes the TAP and the netns; a netns or TAP left behind by a create or clone that was
killed before its record was written is reclaimed by `cocoon-macos gc`.

`vm start` (and a forced restore's relaunch) rebuilds an auto-created TAP (under `cni` also its netns
and TC redirect) with the recorded MAC when the host lost it, e.g. after a reboot.

## VNC exposure

VNC exposure depends on the net mode:
Expand Down Expand Up @@ -83,9 +86,9 @@ retargeted, so the CLI prints that the supplied VNC settings were ignored and ch

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 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.
(not truncated) if the password exceeds 8 bytes or contains whitespace, control characters or double
quotes — QEMU's VNC DES auth only supports 8-byte passwords. Plain VNC clients (RealVNC/TigerVNC) work
without a password on the loopback modes.

> In-guest macOS Screen Sharing (to the guest's own IP) is **not** enabled headlessly — macOS
> requires the Screen Recording TCC grant from the GUI or MDM. Use QEMU's built-in VNC above instead.
Expand Down
33 changes: 18 additions & 15 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,34 @@ module github.com/cocoonstack/cocoon-macos
go 1.27.0

require (
github.com/cocoonstack/cocoon v0.6.9-0.20260923161722-22b7392e16b0
github.com/gofrs/flock v0.13.0
github.com/cocoonstack/cocoon v0.6.9-0.20260924025202-dcec1fa3f056
github.com/gofrs/flock v0.13.1
github.com/opencontainers/image-spec v1.1.1
github.com/projecteru2/core v0.1.3
github.com/projecteru2/core v0.1.5
github.com/spf13/cobra v1.10.2
howett.net/plist v1.0.1
oras.land/oras-go/v2 v2.6.1
oras.land/oras-go/v2 v2.6.2
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cockroachdb/errors v1.14.0 // indirect
github.com/cockroachdb/logtags v0.0.0-20241215232642-bb51bb14a506 // indirect
github.com/cockroachdb/redact v1.1.8 // indirect
github.com/containernetworking/cni v1.3.0 // indirect
github.com/containernetworking/cni v1.3.1 // 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/cli v29.8.0+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.8 // indirect
github.com/docker/go-units v0.5.0 // 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/getsentry/sentry-go v0.49.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-containerregistry v0.21.7 // indirect
github.com/google/go-containerregistry v0.22.1 // 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/klauspost/compress v1.20.0 // 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
Expand All @@ -46,15 +47,17 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
go.opentelemetry.io/otel v1.45.0 // indirect
go.opentelemetry.io/otel/trace v1.45.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.41.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260918162117-cecb64721679 // indirect
google.golang.org/grpc v1.83.2 // 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/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.54.0 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.59.0 // indirect
)
Loading
Loading