diff --git a/cmd/vm/clone.go b/cmd/vm/clone.go index 1bccae6..8e4c249 100644 --- a/cmd/vm/clone.go +++ b/cmd/vm/clone.go @@ -2,6 +2,7 @@ package vm import ( "fmt" + "os" "path/filepath" "time" @@ -38,11 +39,23 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { if err != nil { return err } + storage := srcRec.Storage + if cmd.Flags().Changed("storage") { + storage, err = storageFromFlag(cmd) + if err != nil { + return err + } + } dir, overlay, ovmfVars, digest, err := scaffoldVM(cmd, name, srcRec.Image, srcRec.OVMFVars, filepath.Base(srcRec.OVMFVars)) if err != nil { return err } ctx := cliutil.CommandContext(cmd) + storage, err = resizeSystemDisk(ctx, overlay, storage) + if err != nil { + _ = os.RemoveAll(dir) + return err + } copied, err := copyDataDisks(dir, srcRec.DataDisks) if err != nil { return err @@ -53,7 +66,7 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error { } r := &record{ Name: name, Image: srcRec.Image, ImageDigest: digest, Disk: overlay, - OVMFCode: srcRec.OVMFCode, OVMFVars: ovmfVars, CPUs: srcRec.CPUs, Memory: srcRec.Memory, + OVMFCode: srcRec.OVMFCode, OVMFVars: ovmfVars, CPUs: srcRec.CPUs, Memory: srcRec.Memory, Storage: storage, DataDisks: append(copied, newDisks...), VMID: utils.GenerateID(), Created: time.Now().Format(time.RFC3339), } diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 62e8d58..dc3d876 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -123,6 +123,7 @@ func addVMFlags(cmd *cobra.Command) { cmd.Flags().StringP("name", "n", "", "VM name (default: generated)") cmd.Flags().Int("cpus", 4, "vCPU count") cmd.Flags().String("memory", "8192", "guest memory in MiB") + cmd.Flags().String("storage", "", "system disk size (for example 100Gi); omit to keep the image virtual size, shrinking is rejected") cmd.Flags().Bool("hugepages", false, "back guest RAM with 2 MiB hugepages (needs host hugepages reserved; lower TLB/EPT overhead)") cmd.Flags().StringArray("data-disk", nil, "attach an extra qcow2 data disk: comma-separated key=value (size= required e.g. size=20G; name= optional, default dataN). Repeatable, max 4. macOS has no in-guest agent, so fstype=/mount= are unsupported — format it in the guest (Disk Utility/diskutil)") cmd.Flags().Int("vnc", -1, "VNC display number for the initial boot (n => port 590n); <0 disables. Launch-scoped: cleared on stop, re-enable per start with `vm start --vnc`") diff --git a/cmd/vm/handler.go b/cmd/vm/handler.go index 2453e52..48c7554 100644 --- a/cmd/vm/handler.go +++ b/cmd/vm/handler.go @@ -34,6 +34,7 @@ type record struct { CPUs int `json:"cpus"` Memory string `json:"memory"` + Storage int64 `json:"storage"` // system-disk virtual size in bytes VNCDisp int `json:"vnc"` VNCPass string `json:"-"` // launch-scoped, set from the flag each start; never persisted (would leak at rest) SSHPort int `json:"ssh_port"` diff --git a/cmd/vm/lifecycle.go b/cmd/vm/lifecycle.go index c3745ec..e992a84 100644 --- a/cmd/vm/lifecycle.go +++ b/cmd/vm/lifecycle.go @@ -132,6 +132,10 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { if err = requireCNIVNCPassword(netMode == netCNI, vnc, vncPass); err != nil { return nil, err } + storage, err := storageFromFlag(cmd) + if err != nil { + return nil, err + } oc, code, varsTmpl, err := resolveFirmware(cmd) if err != nil { return nil, err @@ -141,6 +145,11 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { return nil, err } ctx := cliutil.CommandContext(cmd) + storage, err = resizeSystemDisk(ctx, overlay, storage) + if err != nil { + _ = os.RemoveAll(dir) + return nil, err + } cpus, _ := cmd.Flags().GetInt("cpus") mem, _ := cmd.Flags().GetString("memory") ssh, _ := cmd.Flags().GetInt("ssh-port") @@ -148,7 +157,7 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) { huge, _ := cmd.Flags().GetBool("hugepages") r := &record{ Name: name, Image: image, ImageDigest: digest, Disk: overlay, OVMFCode: code, OVMFVars: ovmfVars, - CPUs: cpus, Memory: mem, VNCDisp: vnc, SSHPort: ssh, VNCPass: vncPass, NetMode: netMode, Tap: tap, Hugepages: huge, + CPUs: cpus, Memory: mem, Storage: storage, VNCDisp: vnc, SSHPort: ssh, VNCPass: vncPass, NetMode: netMode, Tap: tap, Hugepages: huge, VMID: utils.GenerateID(), Created: time.Now().Format(time.RFC3339), } if r.DataDisks, err = createDataDisks(ctx, dir, diskSpecs); err != nil { diff --git a/cmd/vm/utils.go b/cmd/vm/utils.go index a363fa2..41d92f5 100644 --- a/cmd/vm/utils.go +++ b/cmd/vm/utils.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/docker/go-units" "github.com/spf13/cobra" "github.com/cocoonstack/cocoon-macos/home" @@ -55,6 +56,48 @@ func bakeOverlay(ctx context.Context, base, dst string) error { return nil } +func storageFromFlag(cmd *cobra.Command) (int64, error) { + raw, _ := cmd.Flags().GetString("storage") + if strings.TrimSpace(raw) == "" { + return 0, nil + } + parsed := strings.TrimSpace(raw) + if strings.HasSuffix(strings.ToLower(parsed), "i") { + parsed = parsed[:len(parsed)-1] + } + n, err := units.RAMInBytes(parsed) + if err != nil { + return 0, fmt.Errorf("invalid --storage %q: %w", raw, err) + } + if n <= 0 { + return 0, fmt.Errorf("invalid --storage %q: size must be positive", raw) + } + return n, nil +} + +// resizeSystemDisk expands a newly-created overlay to target bytes. A zero +// target preserves the image size; shrinking is rejected because qemu-img +// cannot prove that the guest partition/filesystem would remain intact. +func resizeSystemDisk(ctx context.Context, path string, target int64) (int64, error) { + hdr, ok, err := utils.ReadQcow2Header(path) + if err != nil { + return 0, fmt.Errorf("read system disk %s: %w", path, err) + } + if !ok { + return 0, fmt.Errorf("system disk %s is not qcow2", path) + } + if target == 0 || target == hdr.VirtualSize { + return hdr.VirtualSize, nil + } + if target < hdr.VirtualSize { + return 0, fmt.Errorf("--storage %d bytes is smaller than image virtual size %d bytes; shrinking is not supported", target, hdr.VirtualSize) + } + if err := utils.RunQemuImg(ctx, "resize", path, fmt.Sprintf("%d", target)); err != nil { + return 0, fmt.Errorf("resize system disk %s to %d bytes: %w", path, target, err) + } + return target, 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. func scaffoldVM(cmd *cobra.Command, name, image, varsSrc, varsName string) (dir, overlay, ovmfVars, digest string, err error) { dir = home.VMDir(cmd, name) diff --git a/cmd/vm/utils_test.go b/cmd/vm/utils_test.go index 311f2e5..4b3f001 100644 --- a/cmd/vm/utils_test.go +++ b/cmd/vm/utils_test.go @@ -7,6 +7,40 @@ import ( "github.com/spf13/cobra" ) +func TestStorageFromFlag(t *testing.T) { + tests := []struct { + name string + value string + want int64 + wantErr bool + }{ + {name: "unset keeps image size"}, + {name: "kubernetes gibibytes", value: "100Gi", want: 100 << 30}, + {name: "surrounding whitespace", value: " 100Gi ", want: 100 << 30}, + {name: "plain bytes", value: "107374182400", want: 100 << 30}, + {name: "zero rejected", value: "0", wantErr: true}, + {name: "invalid rejected", value: "large", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("storage", "", "") + if tt.value != "" { + if err := cmd.Flags().Set("storage", tt.value); err != nil { + t.Fatal(err) + } + } + got, err := storageFromFlag(cmd) + if (err != nil) != tt.wantErr { + t.Fatalf("storageFromFlag() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("storageFromFlag() = %d, want %d", got, tt.want) + } + }) + } +} + func TestGraceFromFlags(t *testing.T) { tests := []struct { name string diff --git a/docs/cli.md b/docs/cli.md index 19372c2..5c9f90e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,7 +20,7 @@ See [Images](images.md) for the store layout and the parallel-Range download. # clone the golden image into a per-VM overlay and boot it (x86 Linux + /dev/kvm). # IMAGE is a store ref or a direct qcow2 path; firmware defaults to the doctor's install. cocoon-macos vm run ghcr.io/cocoonstack/cocoon-macos/tahoe:26 \ - --name m1 --cpus 4 --memory 8192 --ssh-port 2222 --vnc 1 --random-smbios + --name m1 --cpus 4 --memory 8192 --storage 100Gi --ssh-port 2222 --vnc 1 --random-smbios cocoon-macos vm list # table (NAME STATE CPU MEM NET VNC SSH IMAGE CREATED); -o json for JSON cocoon-macos vm inspect m1 # full record as JSON @@ -33,6 +33,8 @@ cocoon-macos vm rm m1 boot; `start` boots a created/stopped VM. - `run` is atomic: if the boot fails it removes everything it just created (no half-made VM left behind). +- `--storage` expands the new VM's qcow2 system disk before boot. It accepts values such as `100Gi` + or a byte count, never shrinks an image, and is inherited by `clone` unless explicitly overridden. Networking (`--net`) and VNC (`--vnc` / `--vnc-password`) are covered in [Networking & VNC](networking.md); snapshot/clone and `--data-disk` in diff --git a/scripts/provision-macos.sh b/scripts/provision-macos.sh index aab15b8..06357eb 100644 --- a/scripts/provision-macos.sh +++ b/scripts/provision-macos.sh @@ -4,6 +4,7 @@ # - skip Setup Assistant (.AppleSetupDone) # - create a local admin user offline (dscl -f on the volume's dslocal) # - drop a first-boot LaunchDaemon that enables Remote Login (SSH), then self-removes +# - install a persistent LaunchDaemon that grows APFS after qcow2 expansion # Diagnostics are printed so a VNC screenshot shows progress/errors. set -x USER_NAME="${USER_NAME:-cocoon}" @@ -205,4 +206,78 @@ PLIST chown 0:0 "$VOL/Library/LaunchDaemons/com.cocoon.firstboot.plist" "$VOL/usr/local/bin/cocoon-firstboot.sh" chmod 644 "$VOL/Library/LaunchDaemons/com.cocoon.firstboot.plist" ls -la "$VOL/Library/LaunchDaemons/com.cocoon.firstboot.plist" + +# Persistent system-disk growth. qemu-img enlarges the virtual disk before +# boot, but macOS leaves the GPT partition and APFS container at the image's +# original size. The daemon is idempotent and exits quickly when no growth is +# available. +mkdir -p "$VOL/usr/local/sbin" +cat > "$VOL/usr/local/sbin/cocoon-resize-system-disk" <<'SH' +#!/bin/zsh + +set -eu + +log=/var/log/cocoon-resize-system-disk.log +exec >>"$log" 2>&1 + +echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) resize check started" + +physical_store="" +for _ in {1..12}; do + physical_store=$(/usr/sbin/diskutil info / | /usr/bin/awk -F: '/APFS Physical Store/ {gsub(/[[:space:]]/, "", $2); print $2; exit}') + [[ -n "$physical_store" ]] && break + /bin/sleep 5 +done + +if [[ -z "$physical_store" ]]; then + echo "unable to determine the root APFS physical store" + exit 1 +fi + +whole_disk=$(/usr/sbin/diskutil info "$physical_store" | /usr/bin/awk -F: '/Part of Whole/ {gsub(/[[:space:]]/, "", $2); print $2; exit}') +if [[ -z "$whole_disk" ]]; then + echo "unable to determine the whole disk for $physical_store" + exit 1 +fi + +limits=$(/usr/sbin/diskutil apfs resizeContainer "$physical_store" limits) +current=$(printf '%s\n' "$limits" | /usr/bin/sed -n 's/.*Current Container size:.*(\([0-9][0-9]*\) Bytes).*/\1/p') +maximum=$(printf '%s\n' "$limits" | /usr/bin/sed -n 's/.*Maximum.*(\([0-9][0-9]*\) Bytes).*/\1/p') +if [[ -z "$current" || -z "$maximum" ]]; then + echo "unable to parse APFS resize limits for $physical_store" + printf '%s\n' "$limits" + exit 1 +fi + +if (( maximum <= current + 4194304 )); then + echo "no expansion needed: current=$current maximum=$maximum" + exit 0 +fi + +# Refresh the live partition map before growing the APFS container into the +# free space added by qemu-img resize. +/usr/bin/printf 'y\n' | /usr/sbin/diskutil repairDisk "$whole_disk" +/usr/sbin/diskutil apfs resizeContainer "$physical_store" 0 +/bin/sync + +final=$(/usr/sbin/diskutil apfs resizeContainer "$physical_store" limits) +echo "$final" +echo "expanded root APFS container on $physical_store" +SH +chmod 755 "$VOL/usr/local/sbin/cocoon-resize-system-disk" +cat > "$VOL/Library/LaunchDaemons/io.cocoon.resize-system-disk.plist" <<'PLIST' + + + + Labelio.cocoon.resize-system-disk + ProgramArguments/usr/local/sbin/cocoon-resize-system-disk + RunAtLoad + ProcessTypeBackground + StandardOutPath/var/log/cocoon-resize-system-disk.launchd.log + StandardErrorPath/var/log/cocoon-resize-system-disk.launchd.log + +PLIST +chown 0:0 "$VOL/usr/local/sbin/cocoon-resize-system-disk" "$VOL/Library/LaunchDaemons/io.cocoon.resize-system-disk.plist" +chmod 644 "$VOL/Library/LaunchDaemons/io.cocoon.resize-system-disk.plist" +echo "OK installed persistent APFS auto-grow daemon" echo "=== PROVISION DONE (user=$USER_NAME, SSH on first boot) ==="