a73x

8368518c

feat(release): a Mac host gets a build like every other host

a73x   2026-08-06 09:12

Commit message
feat(release): a Mac host gets a build like every other host

The release cross-compiles eitri-agent for darwin/arm64 beside the linux builds
and stages it twice: as eitri-agent_darwin_arm64, the bare binary the manifest
advertises and the self-updater sha-verifies, and inside a darwin host bundle
carrying that binary and its launchd job.

The rest of the path already accommodates it. The manifest builder matches
eitri-agent_<os>_<arch> for any platform, the upgrade endpoint looks an artifact
up by the host row's own os/arch, and self-update replaces the process image
with syscall.Exec, which macOS does exactly as Linux does.

arm64 alone, which is a claim about what has booted a guest rather than about
what compiles. An Intel Mac is told there is no artifact for darwin/amd64 —
true, and checkable — rather than offered a binary nobody has run one on.

The launchd job is the counterpart of the systemd unit and differs where the
platforms do. It runs as a named user rather than root, because on macOS the
guest network belongs to Virtualization.framework and the entitlement for it to
vfkit, not to eitri. It sets PATH, because Homebrew's directory is absent from a
LaunchDaemon's default and vfkit lives there. It needs no counterpart to
KillMode=process, because launchd has no job-wide kill group for a running guest
to be caught in.

The bundle carries no cloud-hypervisor and no firmware: a Mac's runtime is
vfkit, whose entitlement lives in an Apple signature, so it comes from a signed
distribution or not at all — which is why the darwin agent runs no bootstrap.

docs/quickstart.md
Old New
@@ -58,18 +58,26 @@ entitlement lives in a code signature. Homebrew's copy is signed. Without it,
58 VMs placed on this host fail at once, saying so. 58 VMs placed on this host fail at once, saying so.
59 59
60 Take the darwin bundle instead of the linux one, run `eitri-agent join` with 60 Take the darwin bundle instead of the linux one, run `eitri-agent join` with
61 the blob from **+ Add host**, then leave the agent running—there is no launchd 61 the blob from **+ Add host**, then install the launchd job that rides with it:
62 unit in the bundle yet, so it is `sudo eitri-agent` under a supervisor of your
63 choosing.
64 62
65 Apple Silicon runs **arm64 guests only**: the framework cannot emulate another 63 ```sh
66 architecture. Give VMs on a Mac an arm64 image—Ubuntu publishes one beside the 64 sudo cp sh.eitri.agent.plist /Library/LaunchDaemons/
67 amd64 default: 65 sudo chown root:wheel /Library/LaunchDaemons/sh.eitri.agent.plist
68 66 sudo launchctl load -w /Library/LaunchDaemons/sh.eitri.agent.plist
69 ```
70 https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img
71 ``` 67 ```
72 68
69 Set `UserName` in the plist to the account that owns the state directory before
70 you load it. Unlike the Linux agent, this one does not run as root: on Linux
71 the agent owns the host's network—the bridge, the taps, `/dev/kvm`—and on macOS
72 none of that is eitri's, because Virtualization.framework owns the guest
73 network and vfkit carries the entitlement for it.
74
75 Apple Silicon runs **arm64 guests only**: the framework cannot emulate another
76 architecture. You do not have to remember that—`default_images` is keyed by
77 host architecture, so a VM created without an explicit image gets the arm64 one
78 on a Mac. If you *do* name an image, name an arm64 image; nothing downstream
79 checks, and one built for the wrong architecture boots into nothing.
80
73 Guests get their addresses from macOS's own NAT rather than from the agent, so 81 Guests get their addresses from macOS's own NAT rather than from the agent, so
74 a Mac's guests sit on vmnet's subnet, not on the bridge CIDR the console shows. 82 a Mac's guests sit on vmnet's subnet, not on the bridge CIDR the console shows.
75 Everything above that—`eitri ssh`, the console, reconcile—is the same. 83 Everything above that—`eitri ssh`, the console, reconcile—is the same.
@@ -293,6 +301,12 @@ sudo systemctl enable --now eitri-agent
293 The host goes **online** in the console. Logs: 301 The host goes **online** in the console. Logs:
294 `journalctl -u eitri-agent -f`. 302 `journalctl -u eitri-agent -f`.
295 303
304 A Mac joins the same fleet from the `darwin_arm64` bundle—`brew install vfkit`
305 first, then `eitri-agent join`, then the launchd job in place of the systemd
306 unit. The differences are the same ones the hosted section covers above, and
307 the one extra thing self-hosting asks of you is an `arm64` entry in the
308 server's `default_images`.
309
296 ### Boot a VM 310 ### Boot a VM
297 311
298 VMs trust your SSH CA from birth, so register one first. eitri gets the 312 VMs trust your SSH CA from birth, so register one first. eitri gets the
internal/site/manifest_test.go
Old New
@@ -152,3 +152,43 @@ func TestBuildManifestRuntimeArtifactsOptional(t *testing.T) {
152 t.Errorf("firmware key present without CLOUDHV.fd: %v", m.Artifacts["firmware"]) 152 t.Errorf("firmware key present without CLOUDHV.fd: %v", m.Artifacts["firmware"])
153 } 153 }
154 } 154 }
155
156 // TestBuildManifestCarriesADarwinAgent pins that a Mac host's agent manifests
157 // like any other. The upgrade handler looks the artifact up by the host row's
158 // own "<os>/<arch>", so a darwin agent that never reaches the manifest leaves
159 // every Mac in the fleet permanently unable to upgrade — and says so with a
160 // 409, which reads like a server fault rather than a missing build.
161 //
162 // It also pins the negative: a Mac needs no cloud-hypervisor and no CLOUDHV.fd
163 // (its runtime is vfkit, which only Apple can sign), so nothing must invent
164 // darwin entries for those.
165 func TestBuildManifestCarriesADarwinAgent(t *testing.T) {
166 dist := t.TempDir()
167 for _, name := range []string{
168 "eitri-agent_linux_amd64", "eitri-agent_darwin_arm64",
169 "cloud-hypervisor_linux_amd64", "CLOUDHV.fd",
170 } {
171 if err := os.WriteFile(filepath.Join(dist, name), []byte(name), 0o755); err != nil {
172 t.Fatal(err)
173 }
174 }
175 m, err := BuildManifest("v0.0.3", dist, "https://eitri.sh/dl/v0.0.3")
176 if err != nil {
177 t.Fatal(err)
178 }
179 art, ok := m.Artifacts["eitri-agent"]["darwin/arm64"]
180 if !ok {
181 t.Fatal("no eitri-agent artifact for darwin/arm64")
182 }
183 if want := "https://eitri.sh/dl/v0.0.3/eitri-agent_darwin_arm64"; art.URL != want {
184 t.Errorf("url = %q, want %q", art.URL, want)
185 }
186 if art.SHA256 == "" {
187 t.Error("darwin agent artifact has no digest")
188 }
189 for _, key := range []string{"cloud-hypervisor", "firmware"} {
190 if _, ok := m.Artifacts[key]["darwin/arm64"]; ok {
191 t.Errorf("%s must not manifest for darwin — a Mac's runtime is vfkit, which we cannot ship", key)
192 }
193 }
194 }
scripts/release.sh
Old New
@@ -1,10 +1,12 @@
1 #!/usr/bin/env bash 1 #!/usr/bin/env bash
2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/: 2 # Cross-compiled release artifacts for eitri.sh, into dist/<version>/:
3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit 3 # eitri_<v>_linux_{amd64,arm64}.tar.gz host bundle: server+agent+systemd unit
4 # eitri_<v>_darwin_arm64.tar.gz Mac host bundle: agent + launchd job
4 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin 5 # eitri-cli_<v>_<os>_<arch>.tar.gz client CLI (eitri) for linux+darwin
5 # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit 6 # eitri-oidc_<v>_linux_{amd64,arm64}.tar.gz bundled OIDC issuer + its unit
6 # (optional sidecar; not in manifest.json) 7 # (optional sidecar; not in manifest.json)
7 # eitri-agent_linux_{amd64,arm64} bare binaries — what the agent 8 # eitri-agent_{linux_amd64,linux_arm64,darwin_arm64}
9 # bare binaries — what the agent
8 # self-updater downloads and sha-verifies 10 # self-updater downloads and sha-verifies
9 # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream 11 # cloud-hypervisor_linux_{amd64,arm64} pinned runtime, mirrored from upstream
10 # CLOUDHV.fd guest UEFI firmware (if FIRMWARE_SRC set) 12 # CLOUDHV.fd guest UEFI firmware (if FIRMWARE_SRC set)
@@ -60,6 +62,27 @@ for arch in amd64 arm64; do
60 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}" 62 cp "$stage/$bundle/eitri-agent" "$OUT/eitri-agent_linux_${arch}"
61 done 63 done
62 64
65 # macOS host bundle: the agent and its launchd job. arm64 ONLY, and that is a
66 # claim about what has booted a guest, not about what compiles — a darwin/amd64
67 # binary would put an upgrade button in front of an Intel Mac we have never run.
68 # Without one, such a host gets "no eitri-agent artifact for darwin/amd64",
69 # which is the honest answer.
70 #
71 # No server in this bundle: a Mac joins a fleet as a HOST. No cloud-hypervisor
72 # or firmware either — a Mac's runtime is vfkit, which carries an Apple
73 # entitlement in its code signature and so can only come from a signed
74 # distribution (brew install vfkit), never from a mirror of ours. That is why
75 # the darwin agent skips the bootstrap the Linux one runs at startup.
76 mac_bundle="eitri_${VERSION}_darwin_arm64"
77 mac_stage="$STAGE_ROOT/darwin-arm64"
78 mkdir -p "$mac_stage/$mac_bundle"
79 echo "==> building darwin/arm64"
80 CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -trimpath -ldflags "$LDFLAGS" \
81 -o "$mac_stage/$mac_bundle/eitri-agent" ./cmd/eitri-agent
82 cp scripts/sh.eitri.agent.plist "$mac_stage/$mac_bundle/"
83 tar -C "$mac_stage" -czf "$OUT/$mac_bundle.tar.gz" "$mac_bundle"
84 cp "$mac_stage/$mac_bundle/eitri-agent" "$OUT/eitri-agent_darwin_arm64"
85
63 # Bundled OIDC issuer — its own tarball (binary + systemd unit) so running with 86 # Bundled OIDC issuer — its own tarball (binary + systemd unit) so running with
64 # or without local OIDC is a pure deployment choice: a fleet fronted by an 87 # or without local OIDC is a pure deployment choice: a fleet fronted by an
65 # external IdP never downloads it. Like eitri-cli, it is not an agent/runtime 88 # external IdP never downloads it. Like eitri-cli, it is not an agent/runtime
scripts/sh.eitri.agent.plist
Old New
@@ -0,0 +1,72 @@
1 <?xml version="1.0" encoding="UTF-8"?>
2 <!--
3 eitri-agent launchd job — the macOS counterpart of eitri-agent.service.
4 Install on each Mac host:
5
6 sudo cp sh.eitri.agent.plist /Library/LaunchDaemons/
7 sudo chown root:wheel /Library/LaunchDaemons/sh.eitri.agent.plist
8 sudo launchctl load -w /Library/LaunchDaemons/sh.eitri.agent.plist
9
10 A LaunchDaemon, not a LaunchAgent: a fleet host has to run its guests without
11 anyone logged in, and a LaunchAgent only exists inside a login session.
12
13 UserName is deliberate, and the opposite of the Linux unit's User=root. The
14 Linux agent needs root because it owns the host's network — /dev/kvm and
15 /dev/net/tun, the bridge, the taps, nftables, DHCP on :67. On macOS none of
16 that is eitri's: Virtualization.framework owns the guest network through
17 vmnet, and vfkit carries the entitlement that grants it. Set UserName to the
18 account that owns the state directory and has vfkit on its PATH.
19
20 KeepAlive replaces Restart=on-failure. Agent self-upgrades never need it —
21 the agent re-execs in place and keeps its PID, so launchd sees one
22 uninterrupted job. SuccessfulExit=false restarts a crash but not a clean
23 stop, so `launchctl unload` stays a stop rather than a fight.
24
25 There is no macOS equivalent of KillMode=process, and none is needed: launchd
26 does not place children in a job-wide kill group the way a systemd cgroup
27 does, so stopping the agent leaves running guests alone. Guests survive an
28 agent restart on macOS for the same reason they do on Linux — the agent
29 re-adopts them by boot-scoped pidfile.
30 -->
31 <plist version="1.0">
32 <dict>
33 <key>Label</key>
34 <string>sh.eitri.agent</string>
35
36 <key>ProgramArguments</key>
37 <array>
38 <string>/usr/local/bin/eitri-agent</string>
39 <string>--state-dir</string>
40 <string>/usr/local/var/lib/eitri-agent</string>
41 </array>
42
43 <!-- The account that owns the state directory. Guests run as this user. -->
44 <key>UserName</key>
45 <string>CHANGEME</string>
46
47 <key>RunAtLoad</key>
48 <true/>
49 <key>KeepAlive</key>
50 <dict>
51 <key>SuccessfulExit</key>
52 <false/>
53 </dict>
54 <key>ThrottleInterval</key>
55 <integer>5</integer>
56
57 <!-- Homebrew's bin directory is not on a LaunchDaemon's default PATH, and
58 vfkit lives there. Without this the agent starts and then refuses every
59 create in Preflight, which is correct but avoidable. Apple silicon
60 Homebrew is /opt/homebrew; an Intel install is /usr/local. -->
61 <key>EnvironmentVariables</key>
62 <dict>
63 <key>PATH</key>
64 <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
65 </dict>
66
67 <key>StandardOutPath</key>
68 <string>/usr/local/var/log/eitri-agent.log</string>
69 <key>StandardErrorPath</key>
70 <string>/usr/local/var/log/eitri-agent.log</string>
71 </dict>
72 </plist>