macOS: guest agent, online templates, virtio-fs shares, persistent home volume
Apple's built-in QEMU guest agent (AppleQEMUGuestAgent, launched by launchd when a virtio console port org.qemu.guest_agent.0 appears; guest-exec as root) is attached by vmix run --macos and the NixOS module. AppleVirtIO.kext on x86 Tahoe drives virtio-fs, block, console, input, net — verified in QEMU. - customizeImage: `bootScript` — online step through the guest agent (driver mode qga): boot the image, run the script as root with the VMIX volume, shut down through the agent. `as_user` runs commands in the logged-in session. - templates.software: pkg/app (offline in the PE), script/homebrew (online). - templates.profile.settings: widgets, wallpaper (pinned desktoppr — Apple Events need TCC consent that a headless session cannot give), dock apps, autohide, dark mode, hidden files. - generalize: persistHome (fstab LABEL=vmix-home /Users), hideWidgets offline. - formatVolume: formats a blank disk image as APFS by booting the PE (~35 s); idempotent. - NixOS module: macos.guestAgent (/run/vmix/qga-<name>.sock), shares via virtiofsd + vhost-user-fs (Apple automount tag for the first share, others mounted through the agent), macos.homeDisk (created + formatted on first start, virtio-blk), SPICE keeps -vga vmware for macOS. - CLI: vmix run --macos --share DIR --home FILE --qga PATH. - qemu.nix helpers; README section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XsESshRCoBoUVWV9qKURUF
This commit is contained in:
parent
50ad8d521c
commit
0f9373263d
13 changed files with 600 additions and 19 deletions
|
|
@ -9,6 +9,9 @@ Modes
|
|||
(bright) loginwindow / Setup Assistant — the driver powers it down —
|
||||
or halts on its own.
|
||||
boot boot an installed image and wait for it to halt or reach the loginwindow.
|
||||
qga boot an installed image with Apple's QEMU guest agent attached
|
||||
(--qga-sock), wait for it, run --qga-command as root through it
|
||||
(guest-exec), then shut the guest down. Used for online templates.
|
||||
|
||||
Observation is passive: the serial console (boot-args serial=3: the PE's
|
||||
"VMIX-*" markers, kernel boots, panics) and screenshots over QMP (mean
|
||||
|
|
@ -101,6 +104,44 @@ class QMP:
|
|||
self.cmd('system_powerdown')
|
||||
|
||||
|
||||
class QGA:
|
||||
"""Minimal QEMU guest agent client over the unix socket QEMU serves."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
|
||||
def cmd(self, name, timeout=30, **args):
|
||||
s = socket.socket(socket.AF_UNIX)
|
||||
s.settimeout(timeout)
|
||||
try:
|
||||
s.connect(self.path)
|
||||
s.sendall((json.dumps({'execute': name, 'arguments': args}) + '\n').encode())
|
||||
buf = b''
|
||||
while b'\n' not in buf:
|
||||
d = s.recv(65536)
|
||||
if not d:
|
||||
raise OSError('guest agent closed the connection')
|
||||
buf += d
|
||||
finally:
|
||||
s.close()
|
||||
r = json.loads(buf.split(b'\n', 1)[0])
|
||||
if 'error' in r:
|
||||
raise RuntimeError(r['error'])
|
||||
return r.get('return')
|
||||
|
||||
def ping(self):
|
||||
try:
|
||||
return self.cmd('guest-ping', timeout=5) == {}
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
def exec_start(self, command):
|
||||
return self.cmd('guest-exec', path='/bin/bash', arg=['-c', command], **{'capture-output': True})['pid']
|
||||
|
||||
def exec_status(self, pid):
|
||||
return self.cmd('guest-exec-status', pid=pid)
|
||||
|
||||
|
||||
class Screen:
|
||||
"""Screenshots over QMP with a coarse change fingerprint (cursor-insensitive)."""
|
||||
|
||||
|
|
@ -227,6 +268,104 @@ def disk_idle(args):
|
|||
return True
|
||||
|
||||
|
||||
def run_qga(args, proc, qmp, screen, serial, log):
|
||||
"""Online template: wait for the guest agent, run the command, shut down."""
|
||||
import base64
|
||||
qga = QGA(args.qga_sock)
|
||||
start = time.time()
|
||||
last_periodic = 0
|
||||
while not qga.ping():
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
log(f'QEMU exited with {rc} before the guest agent came up')
|
||||
return rc or 3
|
||||
now = time.time()
|
||||
if now - start > args.start_timeout:
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('no-agent')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
log(f'guest agent not reachable within {args.start_timeout:.0f}s')
|
||||
proc.kill()
|
||||
return 3
|
||||
for line in serial.poll():
|
||||
if any(m in line for m in PANIC_MARKS):
|
||||
log('serial: ' + line.strip()[:200])
|
||||
if now - last_periodic > args.periodic:
|
||||
last_periodic = now
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('periodic')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(3)
|
||||
log(f'guest agent up after {time.time() - start:.0f}s: {qga.cmd("guest-info").get("version")}')
|
||||
time.sleep(args.qga_settle) # let the login session / volumes settle
|
||||
try:
|
||||
pid = qga.exec_start(args.qga_command)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'guest-exec failed: {e}')
|
||||
proc.kill()
|
||||
return 3
|
||||
log(f'running command as root (pid {pid}): {args.qga_command[:160]}')
|
||||
t0 = time.time()
|
||||
while True:
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
log(f'QEMU exited with {rc} while the command was running')
|
||||
return rc or 3
|
||||
if time.time() - start > args.timeout:
|
||||
log('timeout reached, killing QEMU')
|
||||
proc.kill()
|
||||
return 124
|
||||
try:
|
||||
st = qga.exec_status(pid)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'guest-exec-status failed: {e}')
|
||||
time.sleep(5)
|
||||
continue
|
||||
if st.get('exited'):
|
||||
break
|
||||
now = time.time()
|
||||
if now - last_periodic > args.periodic:
|
||||
last_periodic = now
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('periodic')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
time.sleep(3)
|
||||
out = base64.b64decode(st.get('out-data', '')).decode('utf-8', 'replace')
|
||||
err = base64.b64decode(st.get('err-data', '')).decode('utf-8', 'replace')
|
||||
code = st.get('exitcode', st.get('signal'))
|
||||
log(f'command finished in {time.time() - t0:.0f}s, exit {code}')
|
||||
for line in (out + err).splitlines()[-200:]:
|
||||
log('guest: ' + line[:220])
|
||||
try:
|
||||
screen.grab()
|
||||
screen.save('after-command')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
log('shutting the guest down')
|
||||
try:
|
||||
qga.exec_start('sync; /sbin/shutdown -h now')
|
||||
except Exception as e: # noqa: BLE001
|
||||
log(f'guest shutdown failed ({e}), ACPI powerdown')
|
||||
try:
|
||||
qmp.system_powerdown()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
for _ in range(180):
|
||||
if proc.poll() is not None:
|
||||
log('QEMU exited')
|
||||
return 0 if code == 0 else 4
|
||||
time.sleep(1)
|
||||
log('guest did not power off, killing QEMU')
|
||||
proc.kill()
|
||||
return 0 if code == 0 else 4
|
||||
|
||||
|
||||
def drive(args, proc, qmp, screen, serial, log):
|
||||
start = time.time()
|
||||
last_periodic = 0
|
||||
|
|
@ -275,11 +414,12 @@ def drive(args, proc, qmp, screen, serial, log):
|
|||
screen.save('panic')
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if args.mode == 'pe' or panics > args.max_resets:
|
||||
if panics > args.max_resets:
|
||||
log(f'kernel panic #{panics}, giving up')
|
||||
proc.kill()
|
||||
return 3
|
||||
# XNU reboots by itself after a panic; only reset if no kernel comes back
|
||||
# XNU reboots by itself after a panic; only reset if no kernel comes back.
|
||||
# (In pe mode the PE then runs run.sh again — templates are idempotent.)
|
||||
log(f'kernel panic #{panics}, waiting for the guest to reboot')
|
||||
serial.reboot_at = now
|
||||
continue
|
||||
|
|
@ -287,7 +427,7 @@ def drive(args, proc, qmp, screen, serial, log):
|
|||
# The guest asked for a reboot but no kernel came back: macOS' restart
|
||||
# path panics in QEMU (AppleSMC watchdog keys, see README); reset now
|
||||
# instead of waiting for the frozen-screen watchdog.
|
||||
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout and args.mode != 'pe':
|
||||
if serial.reboot_at and now - serial.reboot_at > args.reboot_timeout:
|
||||
resets += 1
|
||||
try:
|
||||
screen.grab()
|
||||
|
|
@ -382,7 +522,10 @@ def drive(args, proc, qmp, screen, serial, log):
|
|||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--mode', choices=['pe', 'install', 'boot'], required=True)
|
||||
p.add_argument('--mode', choices=['pe', 'install', 'boot', 'qga'], required=True)
|
||||
p.add_argument('--qga-sock', default=None, help='guest agent unix socket (mode qga)')
|
||||
p.add_argument('--qga-command', default=None, help='bash command to run as root through the guest agent (mode qga)')
|
||||
p.add_argument('--qga-settle', type=float, default=20.0, help='seconds to wait after the agent answers before running the command')
|
||||
p.add_argument('--name', default='macos')
|
||||
p.add_argument('--debug-dir', default=None)
|
||||
p.add_argument('--serial-log', default=None, help='file QEMU writes the serial console to')
|
||||
|
|
@ -422,7 +565,10 @@ def main():
|
|||
screen = Screen(qmp, debug_dir, log)
|
||||
serial = Serial(args.serial_log)
|
||||
try:
|
||||
rc = drive(args, proc, qmp, screen, serial, log)
|
||||
if args.mode == 'qga':
|
||||
rc = run_qga(args, proc, qmp, screen, serial, log)
|
||||
else:
|
||||
rc = drive(args, proc, qmp, screen, serial, log)
|
||||
finally:
|
||||
if args.serial_log and os.path.exists(args.serial_log):
|
||||
try:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue