Skip to content

macOS guests 6/6: experimental machine-image builds - #503

Draft
chruffins wants to merge 14 commits into
feat/macos-guest-portsfrom
feat/macos-image-builds
Draft

chruffins wants to merge 14 commits into
feat/macos-guest-portsfrom
feat/macos-image-builds

Conversation

@chruffins

@chruffins chruffins commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

PR 6 of 6. Experimental machine-image builds for macOS. Base: #514. Not needed for v0; this can land later.

  • Opt-in: macos_builds: true (requires macos_only) lets POST /builds accept machine_base_image (a SHA256-pinned installed base) and a versioned recipe (hypeman-macos-build.json, version 1).
  • Job: the recipe runs as root in an isolated builder VM made from the base. The builder is stopped strictly (acknowledged shutdown and confirmed VMM exit, no forced fallback), then exported and destroyed. The result is streamed to hypeman's own registry under builds/{id}, and the build waits for the existing image-readiness gate.
  • Disk use: the layer is generated twice from the export (once to hash, once to stream) instead of being spooled to disk. With macOS guests 2/6: OCI machine-image integration #499's sparse unpack, a build writes the compressed layer and the disk's real data once each.
  • Recovery: after a restart, interrupted machine jobs are rebuilt from their staged source. The source is kept until the image is ready, and each attempt reaps the previous attempt's builder and workspaces. Linux builder services aren't started in macos_only mode, and Linux jobs stay untouched on disk.
  • Errors report only the failed step and its exit code, or host admission errors such as the base's identity being in use; guest output stays masked.
  • Uploads are authenticated before taking one of 4 upload slots, and capped at 65 MiB.

Linux code touched

  • lib/builds/manager.go: machine jobs dispatch to macos_build_manager.go; the staged machine source is removed once the image is ready.
  • lib/instances/stop.go: a strict stop variant used only for export; a normal stop takes the same path as before.
  • cmd/api: macos_builds config, the upload limiter, and multipart parsing for machine builds. Linux build requests are parsed as before.

Limitations

The recipe format is internal (version 1). Recipe output isn't streamed. No clean-template sanitation policy, toolchain provenance or storage quota yet. registry.url must be hypeman's own registry.

Testing

  • Unit tests cover admission, recipe extraction limits, strict stop and quarantine, malformed exports, masking, publication ordering and verification, streaming without a spool, and recovery of queued, building and pushing jobs.
  • Native QA (Apple silicon, macOS 27.0.1): end-to-end builds through the HTTP API, recipe-step failure, cancellation, API crash during provisioning, publication and conversion (each rebuilt), and host admission errors.

Stack

Each PR is one commit on the one below it. macOS logic lives in its own files (macos*.go, *_darwin.go, exec_lifecycle.go, port_forward.go); shared Linux files get small hooks such as if stored.MacOS != nil.

  1. macOS guests 1/6: VZ runtime, machine images and instance lifecycle #498 VZ runtime, machine images and instance lifecycle
  2. macOS guests 2/6: OCI machine-image integration #499 OCI machine images
  3. macOS guests 3/6: Darwin guest service, readiness and exec #500 Darwin guest agent, readiness and exec
  4. macOS guests 4/6: snapshots, schedules and stopped forks #506 snapshots, standby and stopped clones
  5. macOS guests 5/6: guest-port forwarding #514 guest-port forwarding
  6. macOS guests 6/6: experimental machine-image builds #503 machine-image builds (not needed for v0; can land later)

@chruffins

Copy link
Copy Markdown
Contributor Author

Actual full-size transport QA passed against lib/registry.New(...).Handler() with an empty filesystem-backed destination: streamed the real 28,150,700,918-byte (26.22GiB) compressed installed macOS machine layer plus config over loopback HTTP using monolithic POST /blobs/uploads/?digest=sha256:.... No disk seeding at destination. Server verified streaming digests, then the test independently rehashed persisted blobs, published the original manifest last and fetched it to verify the original manifest SHA256; tag was404 before publication.

Runner/supervisor exit0,47.96s total, sampled peak process-group RSS17.98MiB, minimum free disk491.61GiB. Bounded by1.5GiB RSS/60GiB free disk/20min deadline, nice10/GOMAXPROCS1/GOMEMLIMIT256MiB; no VM ran during upload. Sampled RSS does not include OS cache or account for physical APFS allocation.

This demonstrates a viable streamed transport for the concrete publisher, not a completed PR5 publisher/build API. Cache namespace avoided image conversion; registry auth middleware, remote credentials/redirect/retry/resume and ambiguous commit reconciliation were not exercised. Default go-containerregistry remote.Write PATCH still buffers upload data in0.20.6 even with a disk BlobPutHandler; it was not fixed or passed here. Separately, the earlier pulled artifact passed real normal-API cold boot/lifecycle/recovery; it was not freshly pulled from this upload destination, so these are not an end-to-end build→upload→pull→boot result.

@chruffins

Copy link
Copy Markdown
Contributor Author

Simplification update at ed21906: standalone production MachineBuildRunner replaced by internal MachineBuildBackend executing machine-specific phases through ordinary CreateBuild/runBuild. Normal manager owns queue, persisted request, deadline, private source staging/hash verification, status/log completion, provenance and image-readiness gate. Machine resources inherit base; unsupported Linux builder/cache/secret options fail admission; recovered source is verified before start. Source helper is shared without adding a Linux source-size cap. Normal-manager synthetic tests prove ready/failed status, source consumption and no publication after provision failure. Build race suite passed 3 runs excluding exactly five existing Linux config-volume tests requiring unavailable mkfs.ext4; focused image/machine tests, CGO0 machine/source tests and vet pass. CGO0 VZ OCI round-trip remains unsupported. No concrete VZ/GuestService driver, streamed publisher, recipe, secret/log stream or public HTTP machine mode: still a foundation.

All PRs remain draft. This is source-level/synthetic validation, not a new live boot or production build proof. Default Codex independent review was attempted but is still blocked by authentication (HTTP401). Published atomically after checking reviewer heads with explicit per-ref force-with-lease; prior heads preserved locally.

@chruffins
chruffins force-pushed the feat/macos-image-builds branch from ed21906 to 951e8f6 Compare October 11, 2026 03:36
@chruffins
chruffins changed the base branch from feat/macos-oci-images to feat/macos-guest-service October 11, 2026 03:36
@chruffins

Copy link
Copy Markdown
Contributor Author

Published 951e8f68, restacked on #500 (base updated), with explicit lease against prior ed219069. Still draft.

Implemented concrete GuestService/VZ machine driver, bounded tar.gz/versioned JSON recipe admission, instance-locked strict stop/export, normal-job cancellation/quarantine, and scoped-auth streaming publisher with independent persisted blob verification and manifest-last digest verification. Darwin shutdown now schedules power-off after acceptance so vsock teardown cannot race the reply.

Native isolated QA passed: ordinary queued build → root provisioning → source sanitation → acknowledged no-force shutdown + owned VMM exit → matching export → authenticated28.85GB streaming publication → shared image-readiness gate → fresh empty destination/cache HTTP pull → cold boot → root exec marker + source-absence check. Run483.74s, sampled test-process peak36.875MiB, minimum free313.17GiB. First attempt correctly refused export/publication when shutdown acknowledgement was lost. Final cold-boot test cleanup used ordinary short-timeout forced fallback; that stop was not used for export.

Evidence limits: activation remains internal Go CreateBuild, not HTTP POST /builds/SDK. Base was a manually installed isolated development template, not an automatically provisioned or credential-sanitized production image. Identity exclusivity preserved; no rekey/fork/parallel claim. Common safe recipe logs, clean-base policy, storage quotas, native repeat/cancel/recovery and Linux runtime regressions remain gates.

Repeated focused race regressions and supported full build suite pass; exactly five existing mkfs.ext4-dependent tests excluded on Darwin. Selected CGO0 synthetic tests and vet pass. Default independent autoreview remains401 authentication-blocked, not a completed clean review. All QA VMMs stopped/storage closed; original API and identity slot preserved. See updated docs/macos-builds.md for contracts and remaining gates.

@chruffins

Copy link
Copy Markdown
Contributor Author

Published 0983495e, explicit lease against 951e8f68, still draft/base#500.

Default-off macos_builds now wires the concrete backend into the actual server provider. Requires macos_only and an explicit host registry URL. Existing authenticated POST /builds accepts machine_base_image (installed SHA256-pinned base), source, timeout_seconds, memory_mb, cpus and tags. Source64MiB/fields64KiB/full HTTP body65MiB (before OpenAPI validation); duplicate/unknown/Linux/cache/secret fields and malformed numbers rejected. Linux source parser remains unchanged; Linux servers explicitly reject the machine selector. OpenAPI and generated Go multipart request types updated; external language SDK release pipelines were not operated.

**Real HTTP/provider QA passed:**401 missing/invalid auth,403 read-only scope,202 POST /builds, ordinary queue → real guest provisioning/sanitation → acknowledged no-force export → verified scoped streaming publication → shared readiness → Ready. POST /instances cold-booted the returned image_ref; normal authenticated exec WebSocket verified UID0, the new marker and source absence. New output cold boot used converted cache; previous native run separately proved fresh pull. Private development template remains unsuitable for external distribution without credential sanitation. All QA storage closed/API stopped/slot released; original instance preserved.

Repeated focused API/config/provider/scopes race tests, full config/provider/scopes race tests and vet pass. Default independent autoreview remains401-blocked; no clean review claim. Still gates: external SDK releases, safe recipe logs, clean-base policy, storage quotas, native cancellation/recovery/repeat and Linux runtime validation. Updated docs/macos-builds.md includes opt-in configuration and curl example.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
-->

✱ stlc build

✅ go code · compare

Your SDK build was successful.

generate ✅ → bootstrap ✅ → format ✅

116 files generated at 83a17e9 (pushed)

go get github.com/kernel/hypeman-go-staging@83a17e9c22b3381017a311ac1aa72eaed3876bc4
✅ python code · compare

Your SDK build was successful.

generate ✅ → bootstrap ✅ → format ✅

232 files generated at 557cf64 (pushed)

✅ typescript code · compare

Your SDK build was successful.

generate ✅ → bootstrap ✅ → format ✅

138 files generated at 9d343e5 (pushed)

Diagnostics: ❗ 0 new / 1 total error, 💡 0 new / 5 total note
LevelCodeMessageTargets
Build metadata
Buildbd_76bK2mhv-quiet-mat
Timestamp2026-10-21T20:36:27.944Z
stlc8413509
Spec hashe557b7c00082
Config hash659c3687c3f0

This comment is auto-generated by stlc and is kept up to date as you push.
If you push new commits, re-run this workflow to update this comment.
Last updated: 2026-10-21 20:36:49 UTC

@chruffins
chruffins force-pushed the feat/macos-image-builds branch from 0983495 to d5871ad Compare October 11, 2026 04:41
@chruffins
chruffins force-pushed the feat/macos-image-builds branch 2 times, most recently from 49be29f to b6a7a23 Compare October 11, 2026 16:55
@chruffins
chruffins force-pushed the feat/macos-image-builds branch from b6a7a23 to d5fc996 Compare October 11, 2026 17:16
@chruffins
chruffins force-pushed the feat/macos-image-builds branch from d5fc996 to a47ca78 Compare October 11, 2026 17:30
@chruffins
chruffins force-pushed the feat/macos-image-builds branch from a47ca78 to c4a074d Compare October 11, 2026 18:11
@chruffins chruffins changed the title feat: macOS machine-image build backend (PR5 groundwork) macOS guests 5/6: experimental machine-image builds Oct 11, 2026
@chruffins
chruffins force-pushed the feat/macos-image-builds branch from c4a074d to 53825ac Compare October 11, 2026 21:44
@chruffins
chruffins force-pushed the feat/macos-image-builds branch from 53825ac to 37bc2d3 Compare October 11, 2026 21:49
@chruffins
chruffins force-pushed the feat/macos-image-builds branch 2 times, most recently from 92f19b3 to 4812b8a Compare October 14, 2026 02:27
@chruffins
chruffins force-pushed the feat/macos-image-builds branch 2 times, most recently from 5122239 to 259b0cf Compare October 14, 2026 06:15
@chruffins
chruffins force-pushed the feat/macos-image-builds branch 2 times, most recently from 809ca6a to f361ec8 Compare October 14, 2026 06:51
@chruffins
chruffins force-pushed the feat/macos-image-builds branch from f361ec8 to 71aa094 Compare October 14, 2026 16:38
chruffins and others added 14 commits October 21, 2026 14:55
Shared GuestService on host-CID vsock 2222, root system agent, agent readiness
probe, agent-gated exec, copy and stat, and a macOS stop floor. Darwin exec runs
its own bounded lifecycle in exec_lifecycle.go. Linux exec, boot markers, stop
and the guest client are unchanged.
The guest display was fixed at 1280x800 at 80 ppi. Use 3024x1964 at 160 ppi,
which is 1512x982 points at 2x. Changes the display only on the macOS guest
path; the Linux path is untouched.
The group watcher stopped when cmd.Wait returned. On a TTY, Wait returns as soon
as the shell exits, so a timeout during the output drain never killed a
background child that held the terminal open. The watcher now runs until the
exec lifecycle finishes.

The exit status was bounded only after cancellation. A client that stopped
reading could hold the handler with no cancellation at all. The status send now
has its own deadline, counted from the send.

A timed-out exec whose status cannot be sent still reports the cancellation
error.

Tests: a TTY timeout during the drain kills the background child (failed before
this change), and a stalled client cannot block the exit status.
Readiness probes, the readiness state and shutdown checked SkipGuestAgent alone.
They now use GuestAgentEnabled, which also requires the image to declare the
agent. For Linux guests the two are equal.

macOS instances created before the boot.raw name keep their disk at overlay.raw.
Start and VM-process lookup use overlay.raw when boot.raw is absent. New
instances still use boot.raw.

The guest-operation helper's comment no longer claims to be the only rule.
The direct exec path and the macOS lifecycle each built the command, started
the PTY, ran the stdin and resize pump, and computed the exit status. Those
now live in exec_common.go.

The policy stays in each path: the direct path buffers stdout and waits
without a bound, and the lifecycle streams output and bounds its drain. A
deadline now reports 124 on the direct path too, which matches its existing
comment. Before, a timed-out direct exec reported the signal exit code (-1).
Probes run concurrently with other callers on the same guest, so a failed probe
evicted the pooled connection and cancelled their RPCs. KeepPooledConn only
stopped that eviction.

ProbeExec dials a connection for each attempt and closes it. The pooled
connection is never touched by a probe. The readiness probe uses ProbeExec, and
KeepPooledConn is removed.
Instances created before this change kept their disk at overlay.raw. macOS is
experimental and breaking changes there are acceptable, so there is no fallback.
New instances and existing ones both use boot.raw.
A kill after the leader was reaped could reach a reused process group id. Go's
cmd.Wait reaps the leader, and the lifecycle needed that to read the pipes.

The leader now waits for its exit without being reaped: waitid with WNOWAIT on
Linux, and kqueue NOTE_EXIT on Darwin. Darwin checked on a macOS guest: the exit
event fires before the reap, the group is still signalable, and a watch on an
already-exited child still fires. The lifecycle reads its own pipes, drains
within the bound, and reaps last. processGroup allows a kill only before reap.
cmd.Cancel is now a no-op, so the group watcher is the only killer.

Tests: the reuse guard now uses processGroup. A pipe-drain timeout test covers the
drain on every platform. The TTY drain test is skipped on macOS, which revokes the
terminal when the shell exits.
The shared exit-code helper reported 124 whenever the exec deadline had passed
when the status was computed. On the direct (Linux) path that happens after the
buffered output is sent, so a command that exited in time could report 124 if
delivering its output outlasted the deadline. A timed-out command also changed
from its signal status (-1) to 124.

The direct path now reports the process status, as before the macOS work. The
macOS lifecycle keeps reporting 124 on a deadline.
The macOS agent ships inside its image, so it can be older or newer than the host.
A new GetAgentInfo RPC reports the agent's protocol version, build version and
optional capabilities. The host calls it when a macOS agent first answers and
records the reply on the instance. An agent that predates the RPC answers
Unimplemented and is recorded as protocol 0. An agent newer than the host's
protocol is refused, so the guest stays Initializing with that reason.

Linux guests run the agent embedded in hypeman's initrd, so the host does not call
GetAgentInfo for them.
An image that declares the guest agent but ships a missing, broken or
incompatible one left the guest Initializing indefinitely. On macOS hosts a
controller now stops a guest that has been Initializing for 10 minutes, after one
last probe under the instance lock. The reason, with the last probe failure, is
recorded in the exit message. Disks and logs are kept, start clears the message,
and the restart policy applies as for any other stop. Linux guests are not
checked.
…e, and stopped clones

Stopped snapshots after a graceful stop, same-instance restore through a staged
disk swap, experimental same-instance standby, and clones from a stopped source
with a fresh machine identifier, MAC and SSH host keys.
Forward HTTP and WebSocket requests from /instances/{id}/ports/{port}/* to a TCP
port on a macOS guest's loopback interface, through the guest agent on vsock 2223.
Hypeman does not start or supervise services in the guest; the image does.
Opt-in macos_builds: pinned installed base, versioned recipe, strict export under
the instance lock, streaming publication, abort without export, prior-builder reap.
Linux builds are unchanged.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant