8368518c
feat(release): a Mac host gets a build like every other host
a73x 2026-08-06 09:12
Commit message
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> | ||