Skip to content
Draft
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
15 changes: 14 additions & 1 deletion cmd/vm/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package vm

import (
"fmt"
"os"
"path/filepath"
"time"

Expand Down Expand Up @@ -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
Expand All @@ -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),
}
Expand Down
1 change: 1 addition & 0 deletions cmd/vm/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`")
Expand Down
1 change: 1 addition & 0 deletions cmd/vm/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json:"storage" with no omitempty writes "storage":0 into every record that keeps the image size. add ,omitempty — zero already means 'image virtual size'.

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"`
Expand Down
11 changes: 10 additions & 1 deletion cmd/vm/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -141,14 +145,19 @@ 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")
tap, _ := cmd.Flags().GetString("tap")
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 {
Expand Down
43 changes: 43 additions & 0 deletions cmd/vm/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"
"time"

"github.com/docker/go-units"
"github.com/spf13/cobra"

"github.com/cocoonstack/cocoon-macos/home"
Expand Down Expand Up @@ -55,6 +56,48 @@ func bakeOverlay(ctx context.Context, base, dst string) error {
return nil
}

func storageFromFlag(cmd *cobra.Command) (int64, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reuse + consistency: datadisk.go:81 already parses sizes with units.RAMInBytes (accepts 20G). this reimplements it with a strip-trailing-i hack for a k8s-style 100Gi, so the same binary now speaks two size dialects — data disks want 20G, system disk wants 100Gi. pull one helper into utils.go (parseSize(raw) (int64, error)) and use it for both, and pick one spelling for the flag help + docs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

measured to be precise — units.RAMInBytes natively accepts 100G, 100GB, 100GiB and rejects only bare 100Gi (invalid suffix 'gi'). so the strip-i hack exists solely for the k8s spelling. simplest: drop the hack, document --storage as 100G/100GiB (same dialect data disks already speak), one shared parseSize for both. keep Gi-tolerance only if you really want kube-style input, and then in the shared helper so data disks get it too.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment budget: 3 lines → 1. keep the WHY only:
// resizeSystemDisk grows a new overlay to target bytes (0 = keep image size); shrinking is rejected — qemu-img can't prove the guest fs survives.

// 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)
Expand Down
34 changes: 34 additions & 0 deletions cmd/vm/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
75 changes: 75 additions & 0 deletions scripts/provision-macos.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>io.cocoon.resize-system-disk</string>
<key>ProgramArguments</key><array><string>/usr/local/sbin/cocoon-resize-system-disk</string></array>
<key>RunAtLoad</key><true/>
<key>ProcessType</key><string>Background</string>
<key>StandardOutPath</key><string>/var/log/cocoon-resize-system-disk.launchd.log</string>
<key>StandardErrorPath</key><string>/var/log/cocoon-resize-system-disk.launchd.log</string>
</dict></plist>
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) ==="