Skip to content

Commit fd07528

Browse files
feat: cold-relaunch managed macOS guests on reboot (#28)
1 parent 57a21cf commit fd07528

7 files changed

Lines changed: 52 additions & 21 deletions

File tree

cmd/vm/clone.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ func (h *Handler) Clone(cmd *cobra.Command, args []string) error {
6767
if cmd.Flags().Changed("hugepages") {
6868
r.Hugepages, _ = cmd.Flags().GetBool("hugepages")
6969
}
70+
r.ExitOnReboot = srcRec.ExitOnReboot
71+
if cmd.Flags().Changed("exit-on-reboot") {
72+
r.ExitOnReboot, _ = cmd.Flags().GetBool("exit-on-reboot")
73+
}
7074
r.VNCDisp = vnc
7175
r.SSHPort, _ = cmd.Flags().GetInt("ssh-port")
7276
r.VNCPass = vncPass

cmd/vm/commands.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ func addVMFlags(cmd *cobra.Command) {
124124
cmd.Flags().Int("cpus", 4, "vCPU count")
125125
cmd.Flags().String("memory", "8192", "guest memory in MiB")
126126
cmd.Flags().Bool("hugepages", false, "back guest RAM with 2 MiB hugepages (needs host hugepages reserved; lower TLB/EPT overhead)")
127+
cmd.Flags().Bool("exit-on-reboot", false, "exit QEMU on guest reboot so an external supervisor can relaunch it cold")
127128
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)")
128129
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`")
129130
cmd.Flags().Int("ssh-port", 0, "host port forwarded to guest :22; 0 disables")

cmd/vm/handler.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,14 +32,15 @@ type record struct {
3232
Image string `json:"image"`
3333
ImageDigest string `json:"image_digest,omitempty"`
3434

35-
CPUs int `json:"cpus"`
36-
Memory string `json:"memory"`
37-
VNCDisp int `json:"vnc"`
38-
VNCPass string `json:"-"` // launch-scoped, set from the flag each start; never persisted (would leak at rest)
39-
SSHPort int `json:"ssh_port"`
40-
NetMode string `json:"net_mode,omitempty"`
41-
Hugepages bool `json:"hugepages,omitempty"`
42-
DataDisks []string `json:"data_disks,omitempty"` // created data-disk qcow2 paths, attached on AHCI ports 0,1,3,5
35+
CPUs int `json:"cpus"`
36+
Memory string `json:"memory"`
37+
VNCDisp int `json:"vnc"`
38+
VNCPass string `json:"-"` // launch-scoped, set from the flag each start; never persisted (would leak at rest)
39+
SSHPort int `json:"ssh_port"`
40+
NetMode string `json:"net_mode,omitempty"`
41+
Hugepages bool `json:"hugepages,omitempty"`
42+
ExitOnReboot bool `json:"exit_on_reboot,omitempty"`
43+
DataDisks []string `json:"data_disks,omitempty"` // created data-disk qcow2 paths, attached on AHCI ports 0,1,3,5
4344

4445
Disk string `json:"disk"`
4546
OpenCore string `json:"opencore"`

cmd/vm/lifecycle.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,10 +146,12 @@ func (h *Handler) create(cmd *cobra.Command, image string) (*record, error) {
146146
ssh, _ := cmd.Flags().GetInt("ssh-port")
147147
tap, _ := cmd.Flags().GetString("tap")
148148
huge, _ := cmd.Flags().GetBool("hugepages")
149+
exitOnReboot, _ := cmd.Flags().GetBool("exit-on-reboot")
149150
r := &record{
150151
Name: name, Image: image, ImageDigest: digest, Disk: overlay, OVMFCode: code, OVMFVars: ovmfVars,
151152
CPUs: cpus, Memory: mem, VNCDisp: vnc, SSHPort: ssh, VNCPass: vncPass, NetMode: netMode, Tap: tap, Hugepages: huge,
152-
VMID: utils.GenerateID(), Created: time.Now().Format(time.RFC3339),
153+
ExitOnReboot: exitOnReboot,
154+
VMID: utils.GenerateID(), Created: time.Now().Format(time.RFC3339),
153155
}
154156
if r.DataDisks, err = createDataDisks(ctx, dir, diskSpecs); err != nil {
155157
return nil, err
@@ -180,10 +182,11 @@ func (h *Handler) launch(cmd *cobra.Command, dir string, r *record) error {
180182
spec := qemu.Spec{
181183
Name: r.Name, Disk: r.Disk, OpenCore: r.OpenCore, OVMFCode: r.OVMFCode, OVMFVars: r.OVMFVars,
182184
CPUs: r.CPUs, Memory: r.Memory, VNCDisp: r.VNCDisp, SSHPort: r.SSHPort, MAC: r.MAC, VNCPass: r.VNCPass,
183-
Tap: r.Tap, // set for tap/bridge/cni (a real host TAP); empty => user-mode SLIRP
184-
Hugepages: r.Hugepages,
185-
DataDisks: r.DataDisks,
186-
MonSock: filepath.Join(dir, "monitor.sock"), QMPSock: filepath.Join(dir, "qmp.sock"),
185+
Tap: r.Tap, // set for tap/bridge/cni (a real host TAP); empty => user-mode SLIRP
186+
Hugepages: r.Hugepages,
187+
ExitOnReboot: r.ExitOnReboot,
188+
DataDisks: r.DataDisks,
189+
MonSock: filepath.Join(dir, "monitor.sock"), QMPSock: filepath.Join(dir, "qmp.sock"),
187190
}
188191
// CNI: a 127.0.0.1 VNC inside the netns is unreachable; use a unix socket fronted by startVNCProxy
189192
if r.Netns != "" && r.VNCDisp >= 0 {

docs/cli.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ start — the allocation is not best-effort. On `clone` the flag is only
4545
applied when passed explicitly; otherwise the source VM's setting carries
4646
over.
4747

48+
`--exit-on-reboot` on `create` / `run` / `clone` is for VMs owned by an external
49+
supervisor. It persists with the VM and is inherited by clones. QEMU's
50+
`-no-reboot` exit skips the normal `vm stop` cleanup, so the supervisor must
51+
recover the existing record with `vm start`; standalone VMs keep QEMU's normal
52+
in-process reboot behavior.
53+
4854
## What `vm run` does
4955

5056
1. `qemu-img create -b <golden> overlay.qcow2` — instant copy-on-write clone of the golden image.

qemu/launch.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,15 @@ const (
2323
type Spec struct {
2424
Name string
2525

26-
CPUs int
27-
Memory string // MiB, e.g. "8192"
28-
VNCDisp int // n => host 127.0.0.1:590n; <0 disables
29-
VNCSock string // when set, bind VNC to this unix socket instead of 127.0.0.1 (CNI: fronted by vncProxy)
30-
VNCPass string // set via the monitor post-launch (macOS Screen Sharing needs password auth)
31-
SSHPort int // host port forwarded to guest :22; 0 disables
32-
Hugepages bool // needs host hugepages reserved; off => default RAM
33-
DataDisks []string // extra qcow2 data disks; attached on the AHCI ports MacHDD/OpenCore leave free
26+
CPUs int
27+
Memory string // MiB, e.g. "8192"
28+
VNCDisp int // n => host 127.0.0.1:590n; <0 disables
29+
VNCSock string // when set, bind VNC to this unix socket instead of 127.0.0.1 (CNI: fronted by vncProxy)
30+
VNCPass string // set via the monitor post-launch (macOS Screen Sharing needs password auth)
31+
SSHPort int // host port forwarded to guest :22; 0 disables
32+
Hugepages bool // needs host hugepages reserved; off => default RAM
33+
ExitOnReboot bool // exit QEMU on a guest reboot so an external owner can relaunch it cold
34+
DataDisks []string // extra qcow2 data disks; attached on the AHCI ports MacHDD/OpenCore leave free
3435

3536
Disk string
3637
OpenCore string
@@ -76,6 +77,10 @@ func (s Spec) Args() []string {
7677
"-device", "ide-hd,bus=sata.4,drive=MacHDD",
7778
"-device", "vmware-svga",
7879
}
80+
if s.ExitOnReboot {
81+
// supervisor-owned guest: a reboot request exits QEMU so the owner relaunches it cold.
82+
a = append(a, "-no-reboot")
83+
}
7984
a = append(memBackend, a...) // -object must precede the -machine memory-backend reference
8085
// the SATA ports OpenCoreBoot (sata.2) and MacHDD (sata.4) leave free; the count is capped at 4 upstream
8186
dataDiskPorts := []int{0, 1, 3, 5}

qemu/launch_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ func TestArgsCPU(t *testing.T) {
150150
}
151151
}
152152

153+
func TestArgsGuestRebootExitsQEMU(t *testing.T) {
154+
s := Spec{Disk: "/v/d.qcow2", OpenCore: "/v/oc.qcow2", OVMFCode: "/v/c.fd", OVMFVars: "/v/v.fd", CPUs: 4, Memory: "4096", VNCDisp: -1}
155+
if slices.Contains(s.Args(), "-no-reboot") {
156+
t.Fatalf("standalone VMs must keep QEMU's normal reboot behavior: %v", s.Args())
157+
}
158+
s.ExitOnReboot = true
159+
if !slices.Contains(s.Args(), "-no-reboot") {
160+
t.Fatalf("externally managed VM must exit QEMU for a cold relaunch: %v", s.Args())
161+
}
162+
}
163+
153164
// argVals returns each token immediately following flag in args.
154165
func argVals(args []string, flag string) []string {
155166
var out []string

0 commit comments

Comments
 (0)