a73x

e034a30e

feat(agent): macOS hosts run guests through vfkit

a73x   2026-08-06 09:12

Commit message
feat(agent): macOS hosts run guests through vfkit

A Mac in the fleet boots real Linux guests. internal/agent/vfkit is the second
reconcile.Provisioner: it drives Apple's Virtualization.framework through vfkit,
the signed helper Homebrew installs, one process per VM in its own session so
guests outlive an agent restart — the shape the cloud-hypervisor backend already
has on Linux. Guests boot under the framework's EFI bootloader with per-VM NVRAM,
so they own their kernel exactly as CLOUDHV.fd lets them elsewhere, and the root
disk is an APFS clone of the cached image grown in place. The console is the PTY
vfkit reports over its REST socket, opened raw because a PTY's defaults withhold
anything not ending in a newline — a login prompt among them.

Addressing is where the platforms genuinely differ. macOS keeps it: vmnet's NAT
and its bootpd assign a guest's address once the guest asks, so this backend has
no per-VM host networking to lifecycle and reads the lease instead. That is why
the seam polls Address rather than having Boot return it. Guests now pin their
DHCP client identifier to their MAC, on every platform: left to itself
systemd-networkd sends a DUID, and a lease filed under an identifier the fleet
never chose is a lease nothing can find — while an earlier MAC-keyed offer
lingers to be read as current, which is an address that is not missing but wrong.

vfkit is the one thing the agent cannot install for you, because it works only
carrying Apple's virtualization entitlement and an entitlement lives in a
signature — so a Mac without it refuses at preflight, naming the install, before
a create spends anything. A state directory too deep to hold a VM's control
socket is refused there too: macOS caps a unix socket path where Linux does not.

The inert platform goes with this. It existed because macOS had no backend.

Makefile
Old New
@@ -194,11 +194,12 @@ server-image: web
194 # it, wire it into a real path, or move it into a _test.go. 194 # it, wire it into a real path, or move it into a _test.go.
195 # 195 #
196 # The analysis runs once per platform the agent ships on, because dead means 196 # The analysis runs once per platform the agent ships on, because dead means
197 # dead on ALL of them: hostinfo's Darwin parse helpers and the inert platform 197 # dead on ALL of them: hostinfo's Darwin parse helpers and the whole vfkit
198 # are unreachable on Linux, the whole cloud-hypervisor stack is unreachable on 198 # backend are unreachable on Linux, the whole cloud-hypervisor stack is
199 # Darwin, and none of it is dead. The two runs are intersected — except for 199 # unreachable on Darwin, and none of it is dead. The two runs are intersected
200 # build-tagged files, which are compiled in exactly one run and are therefore 200 # — except for build-tagged files, which are compiled in exactly one run and
201 # judged by that run alone, so dead code inside hostinfo_linux.go still fails. 201 # are therefore judged by that run alone, so dead code inside hostinfo_linux.go
202 # still fails.
202 # 203 #
203 # `go install` into a temp GOBIN rather than `go run`: with GOOS set, `go run` 204 # `go install` into a temp GOBIN rather than `go run`: with GOOS set, `go run`
204 # would cross-compile the analyzer itself instead of the code under analysis. 205 # would cross-compile the analyzer itself instead of the code under analysis.
README.md
Old New
@@ -2,17 +2,19 @@
2 2
3 A control plane for running virtual machines on your own hardware. 3 A control plane for running virtual machines on your own hardware.
4 4
5 eitri turns a pool of Linux hosts into a small VM cloud. You describe the guests 5 eitri turns a pool of machines into a small VM cloud. You describe the guests
6 you want; each host runs an agent that makes reality match that description and 6 you want; each host runs an agent that makes reality match that description and
7 reports back. Guests boot as real [cloud-hypervisor](https://www.cloudhypervisor.org/) 7 reports back. Guests boot as real VMs under UEFI, own their own kernel, and get
8 VMs under UEFI, own their own kernel, and get a sticky IP on a per-host bridge. 8 a sticky IP: on Linux under [cloud-hypervisor](https://www.cloudhypervisor.org/)
9 on a per-host bridge, on macOS under Apple's Virtualization.framework via
10 [vfkit](https://github.com/crc-org/vfkit).
9 11
10 ## How it works 12 ## How it works
11 13
12 eitri is built around a single desired-state loop, the same shape as a kubelet: 14 eitri is built around a single desired-state loop, the same shape as a kubelet:
13 15
14 ``` 16 ```
15 eitri-server ──DesiredStateSnapshot──▶ eitri-agent ──▶ cloud-hypervisor guests 17 eitri-server ──DesiredStateSnapshot──▶ eitri-agent ──▶ guests (the host's VMM)
16 (control plane) (one per host) 18 (control plane) (one per host)
17 ▲ │ 19 ▲ │
18 └──────────ActualStateReport─────────────┘ (also the heartbeat) 20 └──────────ActualStateReport─────────────┘ (also the heartbeat)
@@ -33,9 +35,10 @@ eitri is built around a single desired-state loop, the same shape as a kubelet:
33 from persisted records plus what it observes on the box. 35 from persisted records plus what it observes on the box.
34 36
35 The agent owns everything host-local: resource admission (vCPU / memory / disk / 37 The agent owns everything host-local: resource admission (vCPU / memory / disk /
36 address are admitted through one serialized gate), IP allocation (an embedded 38 address are admitted through one serialized gate), addressing (on Linux an
37 DHCP server hands each VM a sticky, deterministic address and reserves it at 39 embedded DHCP server hands each VM a sticky, deterministic address and reserves
38 create), and a content-addressed image cache (each base image is downloaded and 40 it at create; on macOS the OS's own NAT assigns it and the agent reads the
41 lease), and a content-addressed image cache (each base image is downloaded and
39 decoded to raw once, then reflink-copied per guest). 42 decoded to raw once, then reflink-copied per guest).
40 43
41 ## Components 44 ## Components
@@ -43,7 +46,7 @@ decoded to raw once, then reflink-copied per guest).
43 | Binary | Role | 46 | Binary | Role |
44 | --- | --- | 47 | --- | --- |
45 | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. | 48 | `eitri-server` | Control plane: HTTP API, QUIC sync stream, and the SSH-CA jump gate. |
46 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives cloud-hypervisor. | 49 | `eitri-agent` | Host agent: enrolls a host, reconciles its VMs, drives the host's VMM. |
47 | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. | 50 | `eitri-mcp` | MCP server exposing create/control/destroy VM tools to Claude. |
48 | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. | 51 | `eitri` | Client CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate. |
49 52
@@ -79,9 +82,10 @@ agent runs the reconcile + sync loop against the fleet. Run it under systemd
79 with `scripts/eitri-agent.service`—`Restart=on-failure` revives a crashed 82 with `scripts/eitri-agent.service`—`Restart=on-failure` revives a crashed
80 agent, and its `KillMode=process` keeps running VMs alive across agent stops. 83 agent, and its `KillMode=process` keeps running VMs alive across agent stops.
81 84
82 Guests boot from cloud images (the default is Ubuntu resolute) via UEFI firmware 85 Guests boot from cloud images (the default is Ubuntu resolute) under UEFI, so
83 (`CLOUDHV.fd`) shipped to each host, so the guest owns its kernel and any 86 the guest owns its kernel and any disk-only image boots unmodified. On Linux
84 disk-only image boots unmodified. 87 that is the `CLOUDHV.fd` firmware shipped to each host; on macOS it is the
88 framework's own EFI bootloader, and each guest keeps its NVRAM beside its disk.
85 89
86 ## Documentation 90 ## Documentation
87 91
@@ -97,7 +101,7 @@ it's built this way ([decisions](docs/decisions.md)). What ships next is in
97 ``` 101 ```
98 cmd/ entrypoints (eitri-server, eitri-agent, eitri-mcp, tooling) 102 cmd/ entrypoints (eitri-server, eitri-agent, eitri-mcp, tooling)
99 internal/ 103 internal/
100 agent/ reconcile loop, cloud-hypervisor driver, DHCP, image cache, netenv 104 agent/ reconcile loop, VMM drivers (cloudhv, vfkit), DHCP, image cache, netenv
101 server/ API, QUIC sync service, SSH gate/CA, store, registry, hub 105 server/ API, QUIC sync service, SSH gate/CA, store, registry, hub
102 transport/ QUIC transport shared by both sides 106 transport/ QUIC transport shared by both sides
103 pb/ generated protobuf (proto/eitri/v1) 107 pb/ generated protobuf (proto/eitri/v1)
docs/architecture.md
Old New
@@ -13,7 +13,7 @@ hosts and share only a wire contract:
13 | Plane | Packages | Role | 13 | Plane | Packages | Role |
14 |-------|----------|------| 14 |-------|----------|------|
15 | **Control plane** (server) | `internal/server/*`, `cmd/eitri-server` | Holds *desired* state, tracks *actual* state, exposes the API + SSE UI. Never touches a VM. | 15 | **Control plane** (server) | `internal/server/*`, `cmd/eitri-server` | Holds *desired* state, tracks *actual* state, exposes the API + SSE UI. Never touches a VM. |
16 | **Data plane** (agent) | `internal/agent/*`, `cmd/eitri-agent` | Owns the entire VM lifecycle: disks, networking, cloud-hypervisor processes. | 16 | **Data plane** (agent) | `internal/agent/*`, `cmd/eitri-agent` | Owns the entire VM lifecycle: disks, networking, VMM processes. |
17 | **Wire contract** | `internal/pb`, `internal/transport` | The only code shared across the boundary: protobuf messages + QUIC framing/TLS. | 17 | **Wire contract** | `internal/pb`, `internal/transport` | The only code shared across the boundary: protobuf messages + QUIC framing/TLS. |
18 18
19 The server expresses intent as a `pb.DesiredStateSnapshot` (server → agent); the 19 The server expresses intent as a `pb.DesiredStateSnapshot` (server → agent); the
@@ -38,7 +38,7 @@ bridge IP (`assigned_ip`) via the agent.
38 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | 38 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. |
39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. | 39 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. |
40 | **R5** | The reconcile boundary interface (`Provisioner`) stays consumer-owned and small. A VM's network attachment lives inside it, because on every backend we ship the NIC is a launch argument rather than a separately-lifecycled resource; what crosses the seam is data (the VM's address) and not mechanism (taps, reservations). | Convention (below) + `ireturn` allow-list keeps the seam's interface returns honest. | 40 | **R5** | The reconcile boundary interface (`Provisioner`) stays consumer-owned and small. A VM's network attachment lives inside it, because on every backend we ship the NIC is a launch argument rather than a separately-lifecycled resource; what crosses the seam is data (the VM's address) and not mechanism (taps, reservations). | Convention (below) + `ireturn` allow-list keeps the seam's interface returns honest. |
41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. Checked transitively (reaching `os/exec` via the sanctioned `cloudhv` is fine). | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane` (transitive). | 41 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The exceptions are the provisioners — `agent/cloudhv` and `agent/vfkit` — which launch the long-lived VMM process directly, and the composition root `agent/run`, which builds the Runner it injects. Checked transitively (reaching `os/exec` via a sanctioned package is fine). | `internal/arch` `TestOnlyProvisionersAndRootImportOsExecInDataPlane` (transitive). |
42 | **R13** | The OIDC issuer and the relying party stay separate binaries. `eitri-server` is a pure relying party: the bundled issuer (`internal/oidcprovider`) is importable only by its own binary `cmd/eitri-oidc`, and the `go-oidc` verifier module only by `internal/server/api` (the RP). A server import of the issuer would silently rebuild the embedded-IdP coupling; `go-oidc` anywhere but the RP means a second relying party is being hand-rolled. | `internal/arch` `TestIssuerAndRelyingPartyAreSeparate`. | 42 | **R13** | The OIDC issuer and the relying party stay separate binaries. `eitri-server` is a pure relying party: the bundled issuer (`internal/oidcprovider`) is importable only by its own binary `cmd/eitri-oidc`, and the `go-oidc` verifier module only by `internal/server/api` (the RP). A server import of the issuer would silently rebuild the embedded-IdP coupling; `go-oidc` anywhere but the RP means a second relying party is being hand-rolled. | `internal/arch` `TestIssuerAndRelyingPartyAreSeparate`. |
43 43
44 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see 44 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see
@@ -52,10 +52,10 @@ why the invariants hold:
52 52
53 1. **Consumer-side interfaces only.** An interface is declared by the package 53 1. **Consumer-side interfaces only.** An interface is declared by the package
54 that *uses* it, not the one that implements it. `reconcile` owns 54 that *uses* it, not the one that implements it. `reconcile` owns
55 `Provisioner`; `cloudhv` implements it. `cloudhv` in turn owns `Network`, 55 `Provisioner`; `cloudhv` and `vfkit` implement it. `cloudhv` in turn owns
56 which `netenv` implements. Keep them minimal (`Provisioner` is 6 methods). 56 `Network`, which `netenv` implements. Keep them minimal. This is what let the
57 This is what lets a host backend evolve — a second VMM, a different way of 57 second VMM land — vfkit on macOS, where the host OS assigns guest addresses
58 addressing guests — without touching the reconcile loop. 58 instead of the agent — without touching the reconcile loop.
59 2. **Dependency injection via struct + function fields, no DI framework.** 59 2. **Dependency injection via struct + function fields, no DI framework.**
60 `reconcile.Engine` is the template: collaborators as interface fields 60 `reconcile.Engine` is the template: collaborators as interface fields
61 (`Prov`), pure side effects as func fields (`Images`, `Seed`, `BootID`, 61 (`Prov`), pure side effects as func fields (`Images`, `Seed`, `BootID`,
docs/assumptions.md
Old New
@@ -73,8 +73,9 @@ not viability.
73 vmnet's DHCP assigns after the guest boots, and the lease is readable without 73 vmnet's DHCP assigns after the guest boots, and the lease is readable without
74 privileges. Underpins discovering the address rather than assigning it, and 74 privileges. Underpins discovering the address rather than assigning it, and
75 rejecting both bootpd reservations and a userspace network stack. 75 rejecting both bootpd reservations and a userspace network stack.
76 **Partly proven**: lease read-back spiked on real hardware; no provisioner 76 **Partly proven**: lease read-back spiked on real hardware; the vfkit
77 consumes it yet. 77 provisioner now consumes it, keyed on the VM's deterministic MAC, but no guest
78 has been reached at an address discovered this way.
78 79
79 ### Nobody depends on bring-your-own qcow2 80 ### Nobody depends on bring-your-own qcow2
80 81
@@ -131,11 +132,11 @@ run a guest at all, and treats a permanent refusal as the VM's verdict.
131 Underpins spending nothing—no download, no create slot—on a VM the host can 132 Underpins spending nothing—no download, no create slot—on a VM the host can
132 never boot, and reporting the host's own reason rather than the first expensive 133 never boot, and reporting the host's own reason rather than the first expensive
133 step's symptom. 134 step's symptom.
134 **Partly proven**: pinned in reconcile's tests, and `inert` is the only backend 135 **Partly proven**: pinned in reconcile's tests, and `vfkit` is the only backend
135 that refuses today. It assumes refusal is a static property of the host; a 136 that refuses today — a Mac with no vfkit installed. It assumes refusal is a
136 backend that could run guests only sometimes (a Mac whose VM entitlement comes 137 static property of the host, which is why preflight is asked per create rather
137 and goes) would need preflight consulted per attempt to stay honest, which is 138 than once at start: a host whose answer can change (vfkit installed while the
138 why it is asked per create rather than once at start. 139 agent runs) is then re-asked rather than judged at boot.
139 140
140 ### A gzipped image wraps a raw one 141 ### A gzipped image wraps a raw one
141 142
@@ -177,3 +178,100 @@ directly, and a gzipped qcow2 is refused rather than decoded.
177 **Proven** for the qcow2 case. A gzipped vmdk would slip through as raw and 178 **Proven** for the qcow2 case. A gzipped vmdk would slip through as raw and
178 fail at boot; nobody publishes one, and the fix if they do is to decompress to 179 fail at boot; nobody publishes one, and the fix if they do is to decompress to
179 a temp file and hand it to the decoder. 180 a temp file and hand it to the decoder.
181
182 ### vfkit is the helper we would have written
183
184 Virtualization.framework is reachable only from Objective-C or Swift, so a Mac
185 host needs a signed helper process between the agent and the framework. vfkit is
186 that program already written — Apache-2.0, entitled, Homebrew-installed, one
187 process per VM that outlives its parent. Underpins shipping a macOS backend
188 without a Swift target, a signing identity, or a notarization step of our own.
189 **Unverified as a dependency**: it is maintained for podman and crc, not for us,
190 and its command line is the contract we build against. A breaking change there
191 breaks guest creation on Macs. The escape hatch is that the surface we use is
192 small — a bootloader, three device kinds and two REST calls.
193
194 ### A Mac's guests live outside the CIDR the fleet assigned it
195
196 The server allocates every host a bridge CIDR, and macOS ignores it: vmnet owns
197 its own subnet and its bootpd assigns from that. So a Mac's guests report
198 addresses from a range the console never handed out.
199 **DISPROVEN as harmless** 2026-08-01, on the first real guest. This was written
200 claiming the divergence was harmless because nothing dials a guest from outside
201 its host, so the address is only a label. It is not only a label: the control
202 plane validates it. `store.RecordVMStatus` drops any reported address outside
203 the owning host's `bridge_cidr` — a deliberate guard against APIPA addresses and
204 agent bugs, and a silent one, because logging per tick would spam. So the whole
205 chain worked and the address died at the last hop: vfkit booted the guest,
206 bootpd leased it 192.168.64.7, the agent read the lease and recorded it, and the
207 server discarded it. What that costs was established by experiment on
208 2026-08-02, and it is NOT `eitri ssh`: the gate resolves a connect name to
209 (hostID, vmID) and never reads the stored address (`internal/server/sshgate`),
210 and the agent then dials the address in its OWN record
211 (`internal/agent/syncclient`), which vfkit's `Address` does populate. What
212 breaks is every consumer of the stored `assigned_ip` — the console's IP column,
213 eitri-mcp's wait-for-address (`internal/mcpserver/tools.go`) and the deploy
214 boot-gate (`internal/smoke/scenario.go`), and the last two HANG rather than
215 fail, because both poll for a field that will never arrive. A host whose OS owns
216 addressing has to tell the fleet which subnet its guests are on.
217
218 The same day's live `eitri ssh` against the real macOS guest DID fail, with
219 "cannot reach VM", and that was a separate fault now fixed in the seed: the
220 guest's record held 192.168.64.7, the lease bootpd keyed on the VM's MAC, while
221 the guest had come up on 192.168.64.8 under a DUID client identifier. Two
222 failures, one symptom, and only the second one stopped a connection.
223
224 **Resolved** 2026-08-02: the guard stopped asking about topology. It now asks
225 only whether the value names a guest anything could reach — parses, and is not
226 unspecified, loopback, link-local or multicast — which needs to know nothing
227 about any host. Two halves were needed, because the guard explained a blank
228 address once and the status cache is what made it permanent: `RecordVMStatus`
229 returns the address it wrote, and `syncsvc` caches that rather than the value it
230 sent, so a dropped address no longer suppresses every later report that would
231 have corrected it. **Proven on hardware** 2026-08-03 on an M1 against a
232 Mac-local control plane: `assigned_ip = 192.168.64.10`, an address no fleet
233 allocation contains, accepted and stored. The Linux gate cannot show this — its
234 guests sit inside their own host's `bridge_cidr` and satisfy the old guard too.
235
236 What remains untrue is the host row itself. That same run reports the Mac's
237 `bridge_cidr` as 10.102.1.0/24 while its guests live on 192.168.64.x: the fleet
238 still allocates a subnet to a host whose OS already owns one. The address no
239 longer dies of it, but the row asserts something false, and a host whose OS owns
240 addressing should be telling the fleet which subnet its guests are on rather
241 than being told.
242
243 ### Apple Silicon runs arm64 guests only
244
245 Virtualization.framework cannot emulate a foreign architecture, so a Mac host
246 needs an arm64 image where the rest of the fleet uses amd64.
247 **Proven the hard way** 2026-08-03: a one-click create on an M1 took the fleet's
248 single amd64 default and the guest never booted. Every symptom followed from
249 that one fact — no serial output, so a blank console; no boot, so no DHCP and no
250 address; then the hypervisor exited and the VM was reaped, reported as
251 "ephemeral VM lost", which names neither the image nor the architecture.
252
253 The original conclusion — leave the fleet default alone and pass an arm64 URL
254 per VM — was wrong, because it made a correct create depend on the creator
255 remembering. Both halves are addressed instead. `default_images` is keyed by the
256 architecture of the host a VM lands on, so a one-click create takes the image
257 its host can run, and an architecture with no configured image is refused at
258 create naming it rather than handed something it cannot execute. And a lost
259 guest now quotes whatever its hypervisor said on the way out, so the next
260 failure of this shape reads as a cause rather than an absence.
261
262 An explicit image is still not arch-checked, deliberately: a URL says nothing
263 about what its contents can execute, and guessing from a filename would reject
264 legitimate custom images to catch a mistake the operator made on purpose.
265
266 ### vfkit's command line is what we think it is
267
268 The whole backend is an argv and two REST calls, built against vfkit's
269 documentation rather than against a running Mac. Underpins every claim about
270 how a guest boots here.
271 **Proven on Linux** 2026-08-01, against vfkit's own parser: the exact argv this
272 backend emits is accepted by `cmdline.AddFlags` + `config.BootloaderFromCmdLine`
273 + `AddDevicesFromCmdLine` + `rest.NewEndpoint`, yields the intended machine (EFI
274 bootloader with `createVariableStore`, root disk before seed, NAT'd NIC with our
275 MAC, a pty serial, rng) and round-trips back to the same command line. What that
276 does NOT prove is that the framework then boots it — only that vfkit will not
277 reject it at the door.
docs/quickstart.md
Old New
@@ -15,8 +15,9 @@ sign-in creates your tenant.
15 15
16 ### Join a host 16 ### Join a host
17 17
18 On the machine that will serve VMs (Linux, KVM—see "What you need" 18 On the machine that will serve VMs (Linux with KVM—see "What you need"
19 under Self-hosting), download and verify the host bundle: 19 under Self-hosting, or a Mac, see "Join a Mac"), download and verify the
20 host bundle:
20 21
21 ```sh 22 ```sh
22 V=v0.0.1 23 V=v0.0.1
@@ -41,6 +42,38 @@ sudo systemctl enable --now eitri-agent
41 The agent dials out—a machine behind NAT needs no open ports. It goes 42 The agent dials out—a machine behind NAT needs no open ports. It goes
42 online in the console within seconds. 43 online in the console within seconds.
43 44
45 ### Join a Mac
46
47 A Mac joins the same way, with two differences. It runs guests through
48 [vfkit](https://github.com/crc-org/vfkit) on Apple's Virtualization.framework,
49 and you install that yourself:
50
51 ```sh
52 brew install vfkit
53 ```
54
55 The agent installs cloud-hypervisor on a Linux host but cannot do the same
56 here: vfkit only works carrying Apple's virtualization entitlement, and an
57 entitlement lives in a code signature. Homebrew's copy is signed. Without it,
58 VMs placed on this host fail at once, saying so.
59
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
62 unit in the bundle yet, so it is `sudo eitri-agent` under a supervisor of your
63 choosing.
64
65 Apple Silicon runs **arm64 guests only**: the framework cannot emulate another
66 architecture. Give VMs on a Mac an arm64 image—Ubuntu publishes one beside the
67 amd64 default:
68
69 ```
70 https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img
71 ```
72
73 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.
75 Everything above that—`eitri ssh`, the console, reconcile—is the same.
76
44 ### Boot a VM 77 ### Boot a VM
45 78
46 Console → **+ Create VM**, pick your host, create. Watch it boot in the 79 Console → **+ Create VM**, pick your host, create. Watch it boot in the
@@ -88,8 +121,9 @@ your laptop. `192.0.2.10` is the server below. Substitute yours.
88 121
89 ### What you need 122 ### What you need
90 123
91 Every VM host needs KVM (`ls -l /dev/kvm`). Guest images are decoded in the 124 Every Linux VM host needs KVM (`ls -l /dev/kvm`); a Mac needs `brew install
92 agent, so there is no image toolchain to install. The agent fetches 125 vfkit` (see "Join a Mac"). Guest images are decoded in the
126 agent, so there is no image toolchain to install. On Linux the agent fetches
93 cloud-hypervisor and the 127 cloud-hypervisor and the
94 guest firmware itself on first start, sha-verified against the release. To 128 guest firmware itself on first start, sha-verified against the release. To
95 manage them by hand instead, disable it in `/etc/default/eitri-agent`: 129 manage them by hand instead, disable it in `/etc/default/eitri-agent`:
docs/shape.html
Old New
@@ -184,14 +184,6 @@
184 "imports": [] 184 "imports": []
185 }, 185 },
186 { 186 {
187 "importPath": "internal/agent/inert",
188 "plane": "data",
189 "synopsis": "Package inert is the platform for a host that is in the fleet but cannot run guests.",
190 "imports": [
191 "internal/agent/state"
192 ]
193 },
194 {
195 "importPath": "internal/agent/ipalloc", 187 "importPath": "internal/agent/ipalloc",
196 "plane": "data", 188 "plane": "data",
197 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.", 189 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
@@ -277,6 +269,16 @@
277 ] 269 ]
278 }, 270 },
279 { 271 {
272 "importPath": "internal/agent/vfkit",
273 "plane": "data",
274 "synopsis": "Package vfkit manages one vfkit process per VM: the macOS backend, where vfkit is the signed helper that drives Apple's Virtualization.framework.",
275 "imports": [
276 "internal/agent/exec",
277 "internal/agent/hostinfo",
278 "internal/agent/state"
279 ]
280 },
281 {
280 "importPath": "internal/arch", 282 "importPath": "internal/arch",
281 "plane": "tooling", 283 "plane": "tooling",
282 "synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.", 284 "synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.",
docs/shape.json
Old New
@@ -133,14 +133,6 @@
133 "imports": [] 133 "imports": []
134 }, 134 },
135 { 135 {
136 "importPath": "internal/agent/inert",
137 "plane": "data",
138 "synopsis": "Package inert is the platform for a host that is in the fleet but cannot run guests.",
139 "imports": [
140 "internal/agent/state"
141 ]
142 },
143 {
144 "importPath": "internal/agent/ipalloc", 136 "importPath": "internal/agent/ipalloc",
145 "plane": "data", 137 "plane": "data",
146 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.", 138 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
@@ -226,6 +218,16 @@
226 ] 218 ]
227 }, 219 },
228 { 220 {
221 "importPath": "internal/agent/vfkit",
222 "plane": "data",
223 "synopsis": "Package vfkit manages one vfkit process per VM: the macOS backend, where vfkit is the signed helper that drives Apple's Virtualization.framework.",
224 "imports": [
225 "internal/agent/exec",
226 "internal/agent/hostinfo",
227 "internal/agent/state"
228 ]
229 },
230 {
229 "importPath": "internal/arch", 231 "importPath": "internal/arch",
230 "plane": "tooling", 232 "plane": "tooling",
231 "synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.", 233 "synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.",
internal/agent/imagecache/format.go
Old New
@@ -45,8 +45,8 @@ func sniff(head []byte) imageFormat {
45 45
46 // permanentError marks a failure no retry can fix. reconcile matches the 46 // permanentError marks a failure no retry can fix. reconcile matches the
47 // Permanent() method structurally (errors.As against an anonymous interface), 47 // Permanent() method structurally (errors.As against an anonymous interface),
48 // so this mirrors cloudhv's and inert's markers instead of sharing one: what a 48 // so this mirrors the backends' markers instead of sharing one: what a VMM
49 // VMM driver and an image cache call permanent have nothing else in common. 49 // driver and an image cache call permanent have nothing else in common.
50 type permanentError struct{ err error } 50 type permanentError struct{ err error }
51 51
52 func (e permanentError) Error() string { return e.err.Error() } 52 func (e permanentError) Error() string { return e.err.Error() }
internal/agent/inert/inert.go
Old New
@@ -1,89 +0,0 @@
1 // Package inert is the platform for a host that is in the fleet but cannot run
2 // guests. Every type here satisfies one of the agent's backend seams —
3 // reconcile.Provisioner and serialpump.ConsoleSource — while touching nothing
4 // on the host.
5 //
6 // Refusals are deliberate and loud. VM lifecycle calls fail with a Permanent()
7 // error so reconcile terminal-fails a misplaced VM in one attempt instead of
8 // burning its retry budget against a backend that can never succeed; a console
9 // request fails through the normal pump path rather than silently doing
10 // nothing. Address is the one exception: it answers "" rather than an error,
11 // because not knowing a guest's address is a legitimate state of the seam, not
12 // a refusal.
13 //
14 // The package is untagged so Linux CI proves its behavior, even though only
15 // wire_darwin.go builds it into a binary.
16 package inert
17
18 import (
19 "context"
20 "errors"
21 "fmt"
22 "io"
23 "runtime"
24
25 "github.com/a73x/eitri/internal/agent/state"
26 )
27
28 // The two refusals, exported so callers and tests can match them and an
29 // operator reading a failed VM sees one consistent sentence per cause.
30 var (
31 ErrNoRuntime = errors.New("no VM runtime on this host")
32 ErrNoConsole = errors.New("no guest consoles on this host")
33 )
34
35 // permanentError marks a failure no retry can fix. reconcile matches the
36 // Permanent() method structurally (errors.As against an anonymous interface),
37 // so this mirrors cloudhv's marker instead of sharing one: a shared type would
38 // be the only thing a real VMM driver and this one have in common.
39 type permanentError struct{ err error }
40
41 func (e permanentError) Error() string { return e.err.Error() }
42 func (e permanentError) Unwrap() error { return e.err }
43 func (e permanentError) Permanent() bool { return true }
44
45 // refuse wraps a cause with the host's platform, so the message an operator
46 // reads in the console says which kind of host said no.
47 func refuse(cause error) error {
48 return permanentError{err: fmt.Errorf("%w (%s/%s)", cause, runtime.GOOS, runtime.GOARCH)}
49 }
50
51 // Provisioner is the VM lifecycle on a host with no VM runtime: every call
52 // refuses, and nothing is ever running.
53 type Provisioner struct{}
54
55 // Preflight is where a VM misplaced onto this host should now fail: reconcile
56 // asks it before the image fetch, so the answer an operator reads is this
57 // host's verdict rather than whatever the create's first expensive step
58 // happened to trip over.
59 func (Provisioner) Preflight(_ context.Context) error { return refuse(ErrNoRuntime) }
60
61 func (Provisioner) PrepareRootDisk(_ context.Context, _ state.VMSpec, _ string) error {
62 return refuse(ErrNoRuntime)
63 }
64
65 func (Provisioner) Boot(_ context.Context, _ string, _ state.VMSpec) error {
66 return refuse(ErrNoRuntime)
67 }
68
69 func (Provisioner) Shutdown(_ context.Context, _ string) error { return refuse(ErrNoRuntime) }
70
71 // Destroy refuses like the rest of the lifecycle. Reconcile keeps the VM's
72 // record when Destroy fails, so a VM that somehow landed here is never
73 // hard-deleted from the fleet.
74 func (Provisioner) Destroy(_ context.Context, _ string) error { return refuse(ErrNoRuntime) }
75
76 func (Provisioner) Running(_ string) bool { return false }
77
78 // Address is empty because there are no guests to have one. Reconcile reads
79 // empty as "no answer" and leaves any recorded address alone, which is right:
80 // this platform has nothing to say either way.
81 func (Provisioner) Address(_ string) string { return "" }
82
83 // ConsoleSource is the guest console on a host with no guests. Open fails
84 // rather than handing back a stream that would never carry bytes; the pump
85 // treats that as a source that isn't up yet and backs off, which is the right
86 // shape for a host whose VM runtime isn't there.
87 type ConsoleSource struct{}
88
89 func (ConsoleSource) Open(_ string) (io.ReadWriteCloser, error) { return nil, ErrNoConsole }
internal/agent/inert/inert_test.go
Old New
@@ -1,87 +0,0 @@
1 package inert
2
3 import (
4 "context"
5 "errors"
6 "runtime"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/a73x/eitri/internal/agent/reconcile"
13 "github.com/a73x/eitri/internal/agent/serialpump"
14 "github.com/a73x/eitri/internal/agent/state"
15 )
16
17 // The seams this package exists to satisfy. Asserted here rather than in the
18 // package so its production edges stay at stdlib plus state.
19 var (
20 _ reconcile.Provisioner = Provisioner{}
21 _ serialpump.ConsoleSource = ConsoleSource{}
22 )
23
24 // isPermanent matches the marker the way reconcile does — structurally, on the
25 // method, not on a shared type.
26 func isPermanent(err error) bool {
27 var p interface{ Permanent() bool }
28 return errors.As(err, &p) && p.Permanent()
29 }
30
31 // TestProvisionerRefusesEveryLifecycleCallPermanently is the contract that
32 // makes this platform safe to place a VM against by mistake: reconcile
33 // terminal-fails in one attempt instead of retrying a backend that can never
34 // succeed.
35 func TestProvisionerRefusesEveryLifecycleCallPermanently(t *testing.T) {
36 ctx := context.Background()
37 p := Provisioner{}
38 calls := map[string]error{
39 "Preflight": p.Preflight(ctx),
40 "PrepareRootDisk": p.PrepareRootDisk(ctx, state.VMSpec{}, "/base.img"),
41 "Boot": p.Boot(ctx, "vm-1", state.VMSpec{}),
42 "Shutdown": p.Shutdown(ctx, "vm-1"),
43 "Destroy": p.Destroy(ctx, "vm-1"),
44 }
45 for name, err := range calls {
46 require.Error(t, err, name)
47 assert.ErrorIs(t, err, ErrNoRuntime, name)
48 assert.True(t, isPermanent(err), "%s must be Permanent", name)
49 assert.Contains(t, err.Error(), "no VM runtime", name)
50 }
51 }
52
53 func TestProvisionerNeverReportsRunning(t *testing.T) {
54 assert.False(t, Provisioner{}.Running("vm-1"))
55 }
56
57 func TestProvisionerReportsNoAddress(t *testing.T) {
58 assert.Empty(t, Provisioner{}.Address("vm1"),
59 "a host that runs no guests knows no guest addresses")
60 }
61
62 // TestConsoleSourceFailsLoudly: a console request on this host must produce a
63 // visible error through the normal serialpump path, not a silent nothing. The
64 // error is deliberately NOT permanent — the pump's reconnect loop backs off on
65 // Open failure, which is exactly the behavior wanted for a host whose VM
66 // runtime simply isn't there.
67 func TestConsoleSourceFailsLoudly(t *testing.T) {
68 stream, err := ConsoleSource{}.Open("vm-1")
69 assert.Nil(t, stream)
70 assert.ErrorIs(t, err, ErrNoConsole)
71 assert.False(t, isPermanent(err))
72 }
73
74 // TestRefusalsNameThePlatform: an operator reading a failed VM should learn
75 // which kind of host refused it, not just that something said no.
76 func TestRefusalsNameThePlatform(t *testing.T) {
77 err := Provisioner{}.Boot(context.Background(), "vm-1", state.VMSpec{})
78 assert.Contains(t, err.Error(), runtime.GOOS)
79 assert.Contains(t, err.Error(), runtime.GOARCH)
80 }
81
82 // TestRefusalsUnwrapToTheirCause keeps the marker transparent: wrapping for
83 // the Permanent() signal must not hide the sentinel underneath it.
84 func TestRefusalsUnwrapToTheirCause(t *testing.T) {
85 err := Provisioner{}.Shutdown(context.Background(), "vm-1")
86 assert.Equal(t, ErrNoRuntime, errors.Unwrap(errors.Unwrap(err)))
87 }
internal/agent/reconcile/reconcile.go
Old New
@@ -45,8 +45,8 @@ import (
45 ) 45 )
46 46
47 // Provisioner is one VM's whole lifecycle on this host, networking included. 47 // Provisioner is one VM's whole lifecycle on this host, networking included.
48 // Implemented by the cloud-hypervisor backend (cloudhv.Provisioner) and by 48 // Implemented once per platform: cloudhv.Provisioner drives cloud-hypervisor on
49 // inert.Provisioner on a host that runs no guests. 49 // Linux, vfkit.Provisioner drives Apple's Virtualization.framework on macOS.
50 // 50 //
51 // Networking is part of this seam rather than beside it because a VM's network 51 // Networking is part of this seam rather than beside it because a VM's network
52 // attachment is not separately lifecycled from the VM: cloud-hypervisor takes 52 // attachment is not separately lifecycled from the VM: cloud-hypervisor takes
internal/agent/run/cli.go
Old New
@@ -46,6 +46,7 @@ func hostRunner(ctx context.Context, name string, args ...string) (string, error
46 // Config carries serve's wiring, replacing a long positional list. 46 // Config carries serve's wiring, replacing a long positional list.
47 type Config struct { 47 type Config struct {
48 StateDir, CHBin, Firmware string 48 StateDir, CHBin, Firmware string
49 VfkitBin string
49 BootstrapURL string 50 BootstrapURL string
50 TombstoneGrace, VanishGrace time.Duration 51 TombstoneGrace, VanishGrace time.Duration
51 VMTimeout time.Duration 52 VMTimeout time.Duration
@@ -92,10 +93,15 @@ func parseConfig(args []string) (Config, []string, error) {
92 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError) 93 fs := flag.NewFlagSet("eitri-agent", flag.ContinueOnError)
93 stateDir := fs.String("state-dir", "/var/lib/eitri-agent", "agent state directory") 94 stateDir := fs.String("state-dir", "/var/lib/eitri-agent", "agent state directory")
94 // --ch-bin/--firmware/--bootstrap-url configure the cloud-hypervisor 95 // --ch-bin/--firmware/--bootstrap-url configure the cloud-hypervisor
95 // backend. They are accepted everywhere and ignored by platforms that don't 96 // backend and --vfkit-bin the macOS one. They are accepted everywhere and
96 // run it, so the agent has one flag surface on every host. 97 // ignored by platforms that don't run them, so the agent has one flag
98 // surface on every host.
97 chBin := fs.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary") 99 chBin := fs.String("ch-bin", "cloud-hypervisor", "path to cloud-hypervisor binary")
98 firmware := fs.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)") 100 firmware := fs.String("firmware", "/usr/share/eitri/CLOUDHV.fd", "path to CH UEFI firmware (CLOUDHV.fd)")
101 // Bare name by default: Homebrew installs to /opt/homebrew/bin on Apple
102 // Silicon and /usr/local/bin on Intel, so $PATH is the only answer that is
103 // right on both.
104 vfkitBin := fs.String("vfkit-bin", "vfkit", "path to the vfkit binary (macOS hosts)")
99 bootstrapURL := fs.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)") 105 bootstrapURL := fs.String("bootstrap-url", "https://eitri.sh/dl/latest/manifest.json", "eitri.sh release manifest to fetch cloud-hypervisor/firmware from if missing at startup (empty disables bootstrap)")
100 tombstoneGrace := fs.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy") 106 tombstoneGrace := fs.Duration("tombstone-grace", 5*time.Minute, "quarantine period for tombstoned VMs before destroy")
101 vanishGrace := fs.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone") 107 vanishGrace := fs.Duration("vanish-grace", time.Hour, "quarantine period for VMs that vanished without a tombstone")
@@ -128,6 +134,7 @@ func parseConfig(args []string) (Config, []string, error) {
128 StateDir: *stateDir, 134 StateDir: *stateDir,
129 CHBin: *chBin, 135 CHBin: *chBin,
130 Firmware: *firmware, 136 Firmware: *firmware,
137 VfkitBin: *vfkitBin,
131 BootstrapURL: *bootstrapURL, 138 BootstrapURL: *bootstrapURL,
132 TombstoneGrace: *tombstoneGrace, 139 TombstoneGrace: *tombstoneGrace,
133 VanishGrace: *vanishGrace, 140 VanishGrace: *vanishGrace,
internal/agent/run/wire_darwin.go
Old New
@@ -5,36 +5,41 @@ package run
5 import ( 5 import (
6 "context" 6 "context"
7 7
8 "github.com/a73x/eitri/internal/agent/inert"
9 "github.com/a73x/eitri/internal/agent/reconcile" 8 "github.com/a73x/eitri/internal/agent/reconcile"
10 "github.com/a73x/eitri/internal/agent/serialpump" 9 "github.com/a73x/eitri/internal/agent/serialpump"
11 "github.com/a73x/eitri/internal/agent/state" 10 "github.com/a73x/eitri/internal/agent/state"
11 "github.com/a73x/eitri/internal/agent/vfkit"
12 ) 12 )
13 13
14 // platformProvisioner is what this host advertises to the server at join 14 // platformProvisioner is what this host advertises to the server at join
15 // time — an opaque label the server stores and never interprets. 15 // time — an opaque label the server stores and never interprets.
16 const platformProvisioner = "inert" 16 const platformProvisioner = "vfkit"
17 17
18 // platform is this host's backend pair, mirroring wire_linux.go's contract: 18 // platform is this host's backend pair, mirroring wire_linux.go's contract:
19 // serve() is the sole consumer, wiring Prov and Pumps into the reconcile engine 19 // serve() is the sole consumer, wiring Prov and Pumps into the reconcile engine
20 // and the sync client. This host has no VM runtime, so both come from 20 // and the sync client.
21 // internal/agent/inert, which refuses the work it cannot do rather than
22 // pretending to do it.
23 type platform struct { 21 type platform struct {
24 Prov reconcile.Provisioner 22 Prov reconcile.Provisioner
25 Pumps *serialpump.Manager 23 Pumps *serialpump.Manager
26 } 24 }
27 25
28 // newPlatform builds the inert platform. bridgeCIDR is accepted (the same 26 // newPlatform builds the macOS backend: vfkit over Apple's
29 // signature as every newPlatform, so serve() doesn't need to know which 27 // Virtualization.framework, with the guest console on the PTY vfkit hands out.
30 // platform it got) but unused — the server assigns it, and nothing on this host 28 //
31 // consumes a bridge CIDR without a networking backend. There is no bootstrap 29 // It does nothing before returning, and the two things it does not do are the
32 // step either: unlike Linux, which installs cloud-hypervisor and its UEFI 30 // whole difference from Linux. There is no host networking to set up: vmnet's
33 // firmware on first run, there is no runtime binary to install for a platform 31 // NAT and its bootpd are already running, own the subnet, and hand out the
34 // that runs no guests. 32 // addresses — which is why bridgeCIDR, the CIDR the server assigned this host,
35 func newPlatform(_ context.Context, _ Config, st *state.Store, _ string) (platform, error) { 33 // is accepted for signature parity and then ignored. And there is no runtime to
36 return platform{ 34 // bootstrap: cloud-hypervisor is a binary the agent can fetch and install,
37 Prov: inert.Provisioner{}, 35 // while vfkit works only if it carries Apple's virtualization entitlement, and
38 Pumps: serialpump.NewManager(inert.ConsoleSource{}, st.SerialLogPath), 36 // an entitlement lives in a signature we cannot produce. So a Mac without vfkit
39 }, nil 37 // is not one the agent can fix at startup — it is one whose provisioner refuses
38 // at Preflight, in a sentence naming the install.
39 func newPlatform(_ context.Context, cfg Config, st *state.Store, _ string) (platform, error) {
40 prov := vfkit.New(st, cfg.VfkitBin, hostRunner)
41 pumps := serialpump.NewManager(vfkit.NewConsoleSource(prov.SocketPath), st.SerialLogPath)
42 prov.Pumps = pumps
43
44 return platform{Prov: prov, Pumps: pumps}, nil
40 } 45 }
internal/agent/seed/seed.go
Old New
@@ -225,9 +225,20 @@ func metaData(p Params) string {
225 } 225 }
226 226
227 // networkConfig returns a netplan v2 network-config that DHCPs on the primary 227 // networkConfig returns a netplan v2 network-config that DHCPs on the primary
228 // NIC. The address is served by the host's embedded DHCP responder from the 228 // NIC. The address is served by whichever DHCP server the host runs — the
229 // agent's per-VM reservation, so nothing about addressing is baked into the 229 // agent's own responder on Linux, macOS's bootpd under vmnet — so nothing about
230 // guest image. DNS is supplied by the DHCP server. 230 // addressing is baked into the guest image. DNS comes from the same place.
231 //
232 // dhcp-identifier: mac makes the guest identify itself by its hardware address
233 // instead of the DUID systemd-networkd would otherwise invent. It is load-
234 // bearing on macOS and free on Linux. eitri picks a VM's MAC deterministically
235 // and that is the key everything about addressing hangs off: the agent's
236 // responder looks up reservations by it, and on macOS it is the only handle we
237 // have on a lease we did not grant. A guest that identifies as a DUID is a
238 // guest whose lease is filed under a name the host never chose — bootpd records
239 // it under the identifier, so the address becomes unfindable, and worse, an
240 // earlier MAC-keyed offer can linger and be read as current. Linux is unharmed
241 // because our responder keys on the packet's hardware address either way.
231 func networkConfig() string { 242 func networkConfig() string {
232 return `network: 243 return `network:
233 version: 2 244 version: 2
@@ -236,6 +247,7 @@ func networkConfig() string {
236 match: 247 match:
237 name: "en*" 248 name: "en*"
238 dhcp4: true 249 dhcp4: true
250 dhcp-identifier: mac
239 ` 251 `
240 } 252 }
241 253
internal/agent/seed/seed_test.go
Old New
@@ -92,6 +92,22 @@ func TestNetworkConfigUsesDHCP(t *testing.T) {
92 } 92 }
93 } 93 }
94 94
95 func TestNetworkConfigIdentifiesTheGuestByItsMAC(t *testing.T) {
96 got := networkConfig()
97
98 // Without this, systemd-networkd sends a DUID-based client identifier and
99 // the DHCP server files the lease under that instead of the hardware
100 // address. eitri chooses a VM's MAC deterministically and looks addresses
101 // up by it; on a host whose DHCP server we do not run — macOS's bootpd —
102 // that lookup is the only handle we have, and a DUID makes the live lease
103 // unfindable while an earlier MAC-keyed offer lingers to be misread as
104 // current. Observed for real: a guest held 192.168.64.8 under a DUID while
105 // its stale MAC lease still read 192.168.64.7.
106 if !strings.Contains(got, "dhcp-identifier: mac") {
107 t.Fatalf("network-config must pin the DHCP client identifier to the MAC, got:\n%s", got)
108 }
109 }
110
95 // --- C1b: seed injection-defense tests --- 111 // --- C1b: seed injection-defense tests ---
96 112
97 func TestBuildRejectsNewlineInHostname(t *testing.T) { 113 func TestBuildRejectsNewlineInHostname(t *testing.T) {
internal/agent/vfkit/console.go
Old New
@@ -0,0 +1,193 @@
1 package vfkit
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "net"
9 "net/http"
10 "os"
11 "syscall"
12 "time"
13
14 "golang.org/x/term"
15 )
16
17 // ConsoleSource opens a VM's guest console. It satisfies
18 // serialpump.ConsoleSource; the func it wraps resolves a VM's vfkit REST
19 // socket path (production: Provisioner.SocketPath).
20 //
21 // vfkit does not serve the serial line on a socket the way cloud-hypervisor
22 // does — it allocates a PTY and reports the slave's path. So opening the
23 // console is two steps rather than one: ask the running VM where its PTY is,
24 // then open it. Both fail while the VM is down, and the pump's reopen loop is
25 // the retry for that.
26 //
27 // It is a struct rather than the bare func it wraps because it has to own one
28 // long-lived REST client: see newRESTClient for what a per-call one costs.
29 type ConsoleSource struct {
30 sock func(vmID string) string
31 rest *http.Client
32 }
33
34 // NewConsoleSource builds a console source over sock, which resolves a VM id to
35 // its vfkit REST socket path.
36 func NewConsoleSource(sock func(vmID string) string) *ConsoleSource {
37 return &ConsoleSource{sock: sock, rest: newRESTClient()}
38 }
39
40 // inspectTimeout bounds the /vm/inspect call. It is short because the pump
41 // calls Open inside its reconnect loop: a socket that is present but not
42 // answering must fail fast enough to back off, not park the pump.
43 const inspectTimeout = 3 * time.Second
44
45 // Open returns vmID's guest console as a bidirectional stream. The PTY is
46 // opened O_NOCTTY: without it, the first console the agent opens would become
47 // the agent process's controlling terminal, and a guest that hung up would
48 // deliver SIGHUP to the agent.
49 func (s *ConsoleSource) Open(vmID string) (io.ReadWriteCloser, error) {
50 pty, err := inspectPTY(s.rest, s.sock(vmID))
51 if err != nil {
52 return nil, err
53 }
54 f, err := os.OpenFile(pty, os.O_RDWR|syscall.O_NOCTTY, 0)
55 if err != nil {
56 return nil, err
57 }
58 if err := rawMode(f); err != nil {
59 _ = f.Close()
60 return nil, fmt.Errorf("raw mode %s: %w", pty, err)
61 }
62 return f, nil
63 }
64
65 // rawMode strips the line discipline off a freshly opened PTY slave, because
66 // its defaults are written for a human at a keyboard and this end of the line
67 // is a log drain and a relay.
68 //
69 // ECHO is the destructive one: everything the guest writes arrives in the
70 // slave's input queue, so with echo on the kernel feeds the guest's own boot
71 // log back to it as if someone had typed it — a getty sitting at the login
72 // prompt answers its own output, and the console fills with the guest's replies
73 // to itself. That one is not ours to have caused and is not conditional on this
74 // Open: the pair carries those defaults from the moment vfkit allocates it, so
75 // the echo runs whether or not anything ever opens the slave. We clear it
76 // because we hold the only descriptor that can. ICANON is ours — it withholds
77 // what THIS end reads, and it withholds anything not terminated by
78 // a newline, and the login prompt, the shell prompt and `Password:` are exactly
79 // that, so they never reach serial.log or a live viewer. OPOST would rewrite
80 // the bytes a viewer types on their way to the guest. term.MakeRaw clears all
81 // three (and ISIG with them).
82 //
83 // A path that is not a terminal has no line discipline to configure and is left
84 // alone: nothing in production hands us one — vfkit reports the pty it
85 // allocated — but the check keeps the failure legible instead of turning every
86 // Open against a plain file into an ioctl error.
87 //
88 // The descriptor is borrowed through SyscallConn rather than taken with Fd():
89 // Fd() moves the file out of the runtime poller and leaves it blocking, and the
90 // pump stops a console by closing it to unblock the goroutine parked in Read —
91 // which only works while the poller still owns the descriptor.
92 func rawMode(f *os.File) error {
93 rc, err := f.SyscallConn()
94 if err != nil {
95 return err
96 }
97 var ioctlErr error
98 if err := rc.Control(func(fd uintptr) {
99 if !term.IsTerminal(int(fd)) {
100 return
101 }
102 _, ioctlErr = term.MakeRaw(int(fd))
103 }); err != nil {
104 return err
105 }
106 return ioctlErr
107 }
108
109 // vmInspect is the part of vfkit's /vm/inspect response this package reads: a
110 // device list in which each entry names its own kind. Everything else vfkit
111 // reports about the VM — vcpus, memory, bootloader — is state we passed it, so
112 // there is nothing to learn from reading it back.
113 type vmInspect struct {
114 Devices []struct {
115 Kind string `json:"kind"`
116 PtyName string `json:"ptyName"`
117 } `json:"devices"`
118 }
119
120 // serialKind is how vfkit spells the virtio-serial device in its JSON.
121 const serialKind = "virtioserial"
122
123 // inspectPTY asks the VM on sock where its serial PTY is.
124 func inspectPTY(client *http.Client, sock string) (string, error) {
125 ctx, cancel := context.WithTimeout(withSocket(context.Background(), sock), inspectTimeout)
126 defer cancel()
127
128 req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://vfkit/vm/inspect", nil)
129 if err != nil {
130 return "", err
131 }
132 resp, err := client.Do(req)
133 if err != nil {
134 return "", fmt.Errorf("vfkit inspect: %w", err)
135 }
136 defer resp.Body.Close()
137 if resp.StatusCode >= 300 {
138 return "", fmt.Errorf("vfkit inspect: HTTP %d", resp.StatusCode)
139 }
140
141 var vm vmInspect
142 if err := json.NewDecoder(resp.Body).Decode(&vm); err != nil {
143 return "", fmt.Errorf("vfkit inspect: %w", err)
144 }
145 for _, d := range vm.Devices {
146 if d.Kind == serialKind && d.PtyName != "" {
147 return d.PtyName, nil
148 }
149 }
150 // vfkit fills ptyName in while it builds the VM, so an empty one is the
151 // normal answer for the first moments after Boot, not a broken config.
152 return "", fmt.Errorf("vfkit inspect: no serial PTY yet")
153 }
154
155 // sockKey carries the unix socket a REST request must dial, on the request's
156 // context. It travels there rather than in a per-VM dialer because one client
157 // serves every VM on the host, and the host in the URL — which is what the
158 // connection pool keys on — is the same placeholder for all of them.
159 type sockKey struct{}
160
161 // withSocket marks ctx as belonging to the vfkit listening on sock.
162 func withSocket(ctx context.Context, sock string) context.Context {
163 return context.WithValue(ctx, sockKey{}, sock)
164 }
165
166 // newRESTClient builds the one client its owner uses for every vfkit REST call
167 // it will ever make. Building one per call is what this exists to stop: an
168 // http.Transport with a zero IdleConnTimeout never expires an idle connection,
169 // and each one holds a read goroutine, a write goroutine and a file descriptor
170 // alive for as long as the process runs, with the transport itself unreachable
171 // and so uncollectable. Both callers sit on retry loops — the serial pump
172 // reopens the console until it succeeds, reconcile calls Shutdown every tick
173 // past a stop request — so a VM that never answers leaked until the agent could
174 // no longer open a file.
175 //
176 // Keep-alives are off, and that is what makes one client safe for every VM at
177 // once: a pooled connection is keyed on the URL's host, which is the same
178 // placeholder for all of them, so a connection to one VM's socket could serve
179 // the next VM's inspect. Nothing is given up — a vfkit REST call is a one-shot
180 // against a socket that dies with its VM, and no second request ever follows
181 // close enough to reuse it.
182 func newRESTClient() *http.Client {
183 return &http.Client{
184 Timeout: inspectTimeout,
185 Transport: &http.Transport{
186 DisableKeepAlives: true,
187 DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
188 sock, _ := ctx.Value(sockKey{}).(string)
189 return (&net.Dialer{}).DialContext(ctx, "unix", sock)
190 },
191 },
192 }
193 }
internal/agent/vfkit/console_pty_linux_test.go
Old New
@@ -0,0 +1,184 @@
1 //go:build linux
2
3 package vfkit
4
5 import (
6 "fmt"
7 "io"
8 "net/http"
9 "os"
10 "os/exec"
11 "syscall"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17 "golang.org/x/sys/unix"
18 )
19
20 // The rest of this package's console tests stand a regular file in for the PTY,
21 // which is fair for what Open owes its caller — a stream at the path vfkit
22 // named — and is exactly why the line discipline went unnoticed for so long: a
23 // regular file has none. These tests use a real pty pair, so ECHO and ICANON
24 // are in play the way they are on a Mac. Linux-only for the pty plumbing
25 // (/dev/ptmx, TIOCGPTN); the behaviour under test is POSIX and identical on the
26 // platform this backend ships to.
27
28 // openPTY returns the master half of a fresh pty pair and the path of its
29 // slave — the shape vfkit hands the agent: vfkit holds the master and drives
30 // the guest's serial line through it, and reports the slave for us to open.
31 // The master is left non-blocking so a test can assert that nothing arrived.
32 func openPTY(t *testing.T) (masterFD int, slave string) {
33 t.Helper()
34 master, err := os.OpenFile("/dev/ptmx", os.O_RDWR|syscall.O_NOCTTY, 0)
35 if err != nil {
36 t.Skipf("no pty available on this host: %v", err)
37 }
38 t.Cleanup(func() { _ = master.Close() })
39 fd := int(master.Fd())
40 n, err := unix.IoctlGetInt(fd, unix.TIOCGPTN)
41 if err != nil {
42 t.Skipf("cannot number the pty: %v", err)
43 }
44 // TIOCSPTLCK takes a POINTER to the lock value, so the pointer form of the
45 // ioctl is the one that unlocks rather than returning EFAULT.
46 if err := unix.IoctlSetPointerInt(fd, unix.TIOCSPTLCK, 0); err != nil {
47 t.Skipf("cannot unlock the pty: %v", err)
48 }
49 require.NoError(t, unix.SetNonblock(fd, true))
50 return fd, fmt.Sprintf("/dev/pts/%d", n)
51 }
52
53 // openConsole opens a console over a real pty, the way the pump does: vfkit
54 // reports the slave's path over its REST socket and Open takes it from there.
55 func openConsole(t *testing.T) (console io.ReadWriteCloser, masterFD int) {
56 t.Helper()
57 fd, slave := openPTY(t)
58 sock := inspectServer(t, http.StatusOK,
59 `{"devices":[{"kind":"virtioserial","ptyName":"`+slave+`"}]}`)
60
61 rwc, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
62 require.NoError(t, err)
63 t.Cleanup(func() { _ = rwc.Close() })
64 return rwc, fd
65 }
66
67 // writeGuestOutput puts bytes on the master, which is what the guest writing to
68 // its serial line looks like from this end.
69 func writeGuestOutput(t *testing.T, fd int, s string) {
70 t.Helper()
71 _, err := unix.Write(fd, []byte(s))
72 require.NoError(t, err)
73 }
74
75 // readWithin drains fd until it holds want bytes or the wait runs out. It reads
76 // the descriptor directly, non-blocking, so a discipline that withholds the
77 // bytes fails the test instead of parking it.
78 func readWithin(t *testing.T, fd int, want int, wait time.Duration) string {
79 t.Helper()
80 require.NoError(t, unix.SetNonblock(fd, true))
81 buf := make([]byte, 512)
82 var out []byte
83 for deadline := time.Now().Add(wait); len(out) < want && time.Now().Before(deadline); {
84 n, err := unix.Read(fd, buf)
85 if n > 0 {
86 out = append(out, buf[:n]...)
87 continue
88 }
89 if err != nil && err != unix.EAGAIN && err != unix.EINTR {
90 t.Fatalf("read: %v", err)
91 }
92 time.Sleep(5 * time.Millisecond)
93 }
94 return string(out)
95 }
96
97 // consoleFD is the descriptor behind an opened console.
98 func consoleFD(t *testing.T, console io.ReadWriteCloser) int {
99 t.Helper()
100 f, ok := console.(*os.File)
101 require.True(t, ok, "the console must be a file the test can read directly")
102 return int(f.Fd())
103 }
104
105 func TestConsoleReadsGuestOutputThatHasNoNewline(t *testing.T) {
106 console, master := openConsole(t)
107 const prompt = "ubuntu login: "
108
109 writeGuestOutput(t, master, prompt)
110
111 // Under the default line discipline ICANON withholds everything up to the
112 // next newline, and a login prompt, a shell prompt and `Password:` are all
113 // exactly that — so the moments an operator most needs to see never reach
114 // serial.log or a live viewer at all.
115 assert.Equal(t, prompt, readWithin(t, consoleFD(t, console), len(prompt), 2*time.Second),
116 "the console withheld output that carries no newline")
117 }
118
119 func TestConsoleDoesNotEchoTheGuestsOutputBackAtIt(t *testing.T) {
120 _, master := openConsole(t)
121
122 writeGuestOutput(t, master, "Ubuntu 26.04 LTS ubuntu ttyS0\n")
123
124 // Everything the guest writes lands in the slave's INPUT queue, so with
125 // ECHO on the kernel types the guest's own boot log back at it: a getty at
126 // the login prompt answers itself, and the console fills with the guest
127 // replying to its own output.
128 assert.Empty(t, readWithin(t, master, 1, 200*time.Millisecond),
129 "the guest's own output was echoed back to it as console input")
130 }
131
132 func TestClosingTheConsoleUnblocksAReadInFlight(t *testing.T) {
133 console, _ := openConsole(t)
134 read := make(chan error, 1)
135 go func() {
136 buf := make([]byte, 1)
137 _, err := console.Read(buf)
138 read <- err
139 }()
140 time.Sleep(50 * time.Millisecond) // let the read park
141
142 require.NoError(t, console.Close())
143
144 // This is how the pump stops a console: it closes the stream to release
145 // the goroutine sitting in Read. That only works while the runtime poller
146 // owns the descriptor, so anything Open does to the fd has to borrow it
147 // (SyscallConn) rather than take it (Fd) — see rawMode.
148 select {
149 case <-read:
150 case <-time.After(2 * time.Second):
151 t.Fatal("closing the console left the drain goroutine parked in Read — the pump can never stop it")
152 }
153 }
154
155 // ptyChildEnv marks the re-executed half of the controlling-terminal test.
156 const ptyChildEnv = "EITRI_VFKIT_PTY_CHILD"
157
158 func TestConsoleDoesNotTakeTheAgentsControllingTerminal(t *testing.T) {
159 if os.Getenv(ptyChildEnv) == "1" {
160 openConsole(t)
161 // A process with no controlling terminal cannot open /dev/tty. If this
162 // succeeds, opening the console took one — and every subsequent guest
163 // that hangs up delivers SIGHUP to the agent, killing the fleet's
164 // connection to this host along with every VM's reconcile.
165 f, err := os.OpenFile("/dev/tty", os.O_RDWR|syscall.O_NOCTTY, 0)
166 if err == nil {
167 _ = f.Close()
168 t.Fatal("the guest console became this process's controlling terminal — Open must pass O_NOCTTY")
169 }
170 return
171 }
172
173 // Only a session leader with no controlling terminal can acquire one, and
174 // the test binary is neither, so the check has to run somewhere that is:
175 // Setsid makes the child exactly that. Re-executing this same test is what
176 // keeps the pty and the fake vfkit in one place.
177 cmd := exec.Command(os.Args[0], "-test.run", "^"+t.Name()+"$", "-test.v")
178 cmd.Env = append(os.Environ(), ptyChildEnv+"=1")
179 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
180
181 out, err := cmd.CombinedOutput()
182
183 require.NoError(t, err, string(out))
184 }
internal/agent/vfkit/console_test.go
Old New
@@ -0,0 +1,183 @@
1 package vfkit
2
3 import (
4 "io"
5 "net"
6 "net/http"
7 "os"
8 "path/filepath"
9 "runtime"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 )
16
17 // inspectServer answers /vm/inspect on a unix socket with the given body and
18 // status, and returns the socket path.
19 func inspectServer(t *testing.T, status int, body string) string {
20 t.Helper()
21 sock := filepath.Join(t.TempDir(), "vfkit.sock")
22 ln, err := net.Listen("unix", sock)
23 require.NoError(t, err)
24 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
25 assert.Equal(t, "/vm/inspect", r.URL.Path)
26 w.WriteHeader(status)
27 _, _ = io.WriteString(w, body)
28 })}
29 go func() { _ = srv.Serve(ln) }()
30 t.Cleanup(func() { _ = srv.Close() })
31 return sock
32 }
33
34 func TestConsoleOpensThePTYVfkitReports(t *testing.T) {
35 // A regular file stands in for the PTY slave: what Open owes its caller is
36 // a bidirectional stream at the path vfkit named, and the pty-ness of that
37 // path is the kernel's business, not this package's.
38 pty := filepath.Join(t.TempDir(), "ttys004")
39 require.NoError(t, os.WriteFile(pty, nil, 0o600))
40 sock := inspectServer(t, http.StatusOK, `{"vcpus":2,"devices":[
41 {"kind":"virtioblk","imagePath":"/disk.raw"},
42 {"kind":"virtioserial","usesPty":true,"ptyName":"`+pty+`"},
43 {"kind":"virtiorng"}]}`)
44
45 rwc, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
46
47 require.NoError(t, err)
48 defer rwc.Close()
49 // Writable as well as readable: the console carries keystrokes back to the
50 // guest, which is why this backend asks vfkit for a pty and not a log file.
51 _, err = rwc.Write([]byte("uname -a\n"))
52 assert.NoError(t, err)
53 require.NoError(t, rwc.Close())
54 got, err := os.ReadFile(pty)
55 require.NoError(t, err)
56 assert.Equal(t, "uname -a\n", string(got))
57 }
58
59 func TestConsoleFailsWhileTheVMIsStillComingUp(t *testing.T) {
60 // vfkit fills ptyName in as it builds the VM, so a serial device with no
61 // pty yet is the normal answer in the first moments after Boot. The pump's
62 // reopen loop is the retry — Open must fail rather than hand back nothing.
63 sock := inspectServer(t, http.StatusOK, `{"devices":[{"kind":"virtioserial","usesPty":true}]}`)
64
65 _, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
66
67 require.Error(t, err)
68 assert.Contains(t, err.Error(), "no serial PTY")
69 }
70
71 func TestConsoleFailsWhenTheVMHasNoSerialDevice(t *testing.T) {
72 sock := inspectServer(t, http.StatusOK, `{"devices":[{"kind":"virtioblk"}]}`)
73
74 _, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
75
76 assert.Error(t, err)
77 }
78
79 func TestConsoleFailsWhenVfkitIsNotListening(t *testing.T) {
80 missing := filepath.Join(t.TempDir(), "vfkit.sock")
81
82 // The VM is down, or was never booted. This is the pump's steady state
83 // between a Shutdown and the next Boot, so it must be an ordinary error.
84 _, err := NewConsoleSource(func(string) string { return missing }).Open("vm-1")
85
86 require.Error(t, err)
87 assert.Contains(t, err.Error(), "vfkit inspect")
88 }
89
90 func TestConsoleFailsOnAnUnhappyVfkit(t *testing.T) {
91 sock := inspectServer(t, http.StatusInternalServerError, `{"error":"boom"}`)
92
93 _, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
94
95 require.Error(t, err)
96 assert.Contains(t, err.Error(), "500")
97 }
98
99 func TestConsoleFailsOnAnUnreadableAnswer(t *testing.T) {
100 sock := inspectServer(t, http.StatusOK, `not json`)
101
102 _, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
103
104 assert.Error(t, err)
105 }
106
107 func TestConsoleFailsWhenThePTYPathIsGone(t *testing.T) {
108 gone := filepath.Join(t.TempDir(), "ttys004")
109 sock := inspectServer(t, http.StatusOK,
110 `{"devices":[{"kind":"virtioserial","ptyName":"`+gone+`"}]}`)
111
112 _, err := NewConsoleSource(func(string) string { return sock }).Open("vm-1")
113
114 assert.Error(t, err)
115 }
116
117 // restCallCount is how many REST calls the leak tests make. Each leaked
118 // connection costs a read goroutine and a write goroutine, so this is an order
119 // of magnitude clear of the tolerance below.
120 const restCallCount = 100
121
122 // assertNoGoroutineGrowth fails if call leaves goroutines behind. It is how the
123 // REST client's lifetime is pinned: one built per call never expires its idle
124 // connections, and each one parks a readLoop and a writeLoop forever. The
125 // tolerance is for the server side's own bookkeeping, which drains on its own
126 // schedule — hence the wait rather than a single reading.
127 func assertNoGoroutineGrowth(t *testing.T, call func()) {
128 t.Helper()
129 before := runtime.NumGoroutine()
130 call()
131 // Poll by hand rather than require.Eventually: the counts belong in the
132 // failure message, and testify evaluates those before the condition runs.
133 after := runtime.NumGoroutine()
134 for deadline := time.Now().Add(5 * time.Second); after >= before+10 && time.Now().Before(deadline); {
135 time.Sleep(50 * time.Millisecond)
136 after = runtime.NumGoroutine()
137 }
138 assert.Less(t, after, before+10,
139 "%d REST calls left goroutines behind: %d before, %d after", restCallCount, before, after)
140 }
141
142 func TestConsoleDoesNotLeakAConnectionPerInspect(t *testing.T) {
143 pty := filepath.Join(t.TempDir(), "ttys004")
144 require.NoError(t, os.WriteFile(pty, nil, 0o600))
145 sock := inspectServer(t, http.StatusOK,
146 `{"devices":[{"kind":"virtioserial","ptyName":"`+pty+`"}]}`)
147 s := NewConsoleSource(func(string) string { return sock })
148 // One call first, so the server's own machinery is up and the measurement
149 // covers only what the loop adds.
150 rwc, err := s.Open("vm-1")
151 require.NoError(t, err)
152 require.NoError(t, rwc.Close())
153
154 // Open runs inside the pump's reconnect loop, so this is not a synthetic
155 // volume: a VM whose PTY never opens is asked this many times in minutes.
156 assertNoGoroutineGrowth(t, func() {
157 for range restCallCount {
158 rwc, err := s.Open("vm-1")
159 require.NoError(t, err)
160 require.NoError(t, rwc.Close())
161 }
162 })
163 }
164
165 func TestConsoleAsksEachVMsOwnSocket(t *testing.T) {
166 pty := filepath.Join(t.TempDir(), "ttys004")
167 require.NoError(t, os.WriteFile(pty, nil, 0o600))
168 socks := map[string]string{}
169 for _, vmID := range []string{"vm-1", "vm-2"} {
170 socks[vmID] = inspectServer(t, http.StatusOK,
171 `{"devices":[{"kind":"virtioserial","ptyName":"`+pty+`"}]}`)
172 }
173 s := NewConsoleSource(func(vmID string) string { return socks[vmID] })
174
175 // One client serves every VM, and a connection is pooled under the URL's
176 // host — the same placeholder for all of them — so the second VM's inspect
177 // must not be able to ride the first VM's connection to the wrong socket.
178 for vmID := range socks {
179 rwc, err := s.Open(vmID)
180 require.NoError(t, err, vmID)
181 require.NoError(t, rwc.Close())
182 }
183 }
internal/agent/vfkit/leases.go
Old New
@@ -0,0 +1,130 @@
1 package vfkit
2
3 import (
4 "net/netip"
5 "os"
6 "strconv"
7 "strings"
8
9 "github.com/a73x/eitri/internal/agent/state"
10 )
11
12 // Address returns the address macOS gave this VM's guest, or "" when there is
13 // no answer yet.
14 //
15 // This backend reads addresses; it does not assign them. Under vmnet NAT the
16 // host's own bootpd is the DHCP server, and it decides — there is no hook to
17 // reserve an address in advance that does not require editing /etc/bootptab as
18 // root and restarting a system daemon under the fleet's feet. So the address
19 // does not exist until the guest has booted and asked for one, which is why
20 // the seam polls Address rather than having Boot return it.
21 //
22 // The lease is keyed on the VM's deterministic MAC, so stickiness survives
23 // anyway: the same VM asks with the same hardware address and bootpd offers it
24 // the lease it already holds.
25 func (p *Provisioner) Address(vmID string) string {
26 raw, err := os.ReadFile(p.leasesPath)
27 if err != nil {
28 // No lease database yet — no guest on this host has ever asked for an
29 // address. "" means "no answer", which is what reconcile reads it as.
30 return ""
31 }
32 return leaseAddress(string(raw), state.MAC(vmID))
33 }
34
35 // leaseAddress finds the address most recently leased to mac in the contents
36 // of macOS's dhcpd_leases file, or "" if there is none. The file is a series
37 // of brace-delimited stanzas of key=value lines:
38 //
39 // {
40 // name=ubuntu
41 // ip_address=192.168.64.7
42 // hw_address=1,52:54:0:3a:9f:c1
43 // identifier=1,52:54:0:3a:9f:c1
44 // lease=0x68a1b2c3
45 // }
46 //
47 // The last matching stanza wins, and the honest reason is that a second one
48 // should not exist. A guest asks under one hardware address, and the seed pins
49 // its DHCP client identifier to that same address, so bootpd has one client to
50 // file it under. Where a duplicate DOES arise the file gives no way to rank
51 // them: `lease` is the epoch the lease EXPIRES at, not the moment it was
52 // granted, and bootpd honours a client's requested duration — so a longer older
53 // grant outlives a shorter newer one and expiry is not recency. Position is no
54 // better; nothing bootpd documents fixes the write order, and the one database
55 // captured on real hardware cannot settle it, because its two stanzas were
56 // keyed differently (one by a DUID, one by the MAC) and only ever one of them
57 // was a candidate. So: take the last, and treat two candidates as the anomaly
58 // they are rather than pretending to arbitrate between them.
59 func leaseAddress(leases, mac string) string {
60 want := normalizeMAC(mac)
61 if want == "" {
62 return ""
63 }
64 var ip, hw, found string
65 for _, line := range strings.Split(leases, "\n") {
66 field := strings.TrimSpace(line)
67 switch field {
68 case "{":
69 ip, hw = "", ""
70 case "}":
71 if hw == want && ip != "" {
72 found = ip
73 }
74 default:
75 key, value, ok := strings.Cut(field, "=")
76 if !ok {
77 continue
78 }
79 switch key {
80 case "ip_address":
81 ip = parseIP(value)
82 case "hw_address":
83 hw = normalizeMAC(value)
84 }
85 }
86 }
87 return found
88 }
89
90 // parseIP keeps a lease's address only if it is one. The field is written by a
91 // system daemon the agent does not control, and hw_address is already checked
92 // exhaustively; without the same treatment here, a stanza carrying this VM's
93 // MAC and any non-empty junk becomes the VM's recorded address, and the damage
94 // surfaces a layer away as a guest nothing can reach. Returns "" for anything
95 // that is not an address, which the caller reads as "this stanza has none".
96 func parseIP(s string) string {
97 addr, err := netip.ParseAddr(strings.TrimSpace(s))
98 if err != nil {
99 return ""
100 }
101 return addr.String()
102 }
103
104 // normalizeMAC reduces a hardware address to one comparable form, because the
105 // two sides spell the same address differently: state.MAC pads every octet
106 // ("52:54:00:0a:…") while bootpd writes them bare and prefixes the ARP
107 // hardware type ("1,52:54:0:a:…"). Comparing the strings as written would miss
108 // every lease. Returns "" for anything that is not six hex octets, so garbage
109 // in the file can never match a real MAC.
110 func normalizeMAC(s string) string {
111 if _, rest, ok := strings.Cut(s, ","); ok {
112 s = rest // drop bootpd's hardware-type prefix
113 }
114 octets := strings.Split(strings.TrimSpace(s), ":")
115 if len(octets) != 6 {
116 return ""
117 }
118 var b strings.Builder
119 for i, o := range octets {
120 v, err := strconv.ParseUint(o, 16, 8)
121 if err != nil {
122 return ""
123 }
124 if i > 0 {
125 b.WriteByte(':')
126 }
127 b.WriteString(strconv.FormatUint(v, 16))
128 }
129 return b.String()
130 }
internal/agent/vfkit/leases_test.go
Old New
@@ -0,0 +1,141 @@
1 package vfkit
2
3 import (
4 "os"
5 "path/filepath"
6 "strconv"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "github.com/a73x/eitri/internal/agent/state"
14 )
15
16 // lease renders one dhcpd_leases stanza in macOS's format: bootpd writes octets
17 // unpadded, prefixes the ARP hardware type, and stamps the expiry epoch as hex.
18 func lease(name, ip, mac string, expiry uint64) string {
19 return "{\n\tname=" + name + "\n\tip_address=" + ip + "\n\thw_address=1," + mac +
20 "\n\tidentifier=1," + mac + "\n\tlease=0x" + strconv.FormatUint(expiry, 16) + "\n}\n"
21 }
22
23 // Two expiries a day apart, so which one a test means is never in doubt.
24 const (
25 older = 0x68a1b2c3
26 newer = 0x68a30443
27 )
28
29 func TestLeaseAddressFindsTheGuestByItsMAC(t *testing.T) {
30 // state.MAC pads every octet; bootpd does not. Matching the two as written
31 // would miss every lease this backend ever needs to read.
32 const padded = "52:54:00:0a:0b:0c"
33 leases := lease("other", "192.168.64.2", "52:54:0:ff:ee:dd", newer) +
34 lease("web", "192.168.64.7", "52:54:0:a:b:c", newer)
35
36 assert.Equal(t, "192.168.64.7", leaseAddress(leases, padded))
37 }
38
39 func TestLeaseAddressIgnoresALeaseKeyedByADUID(t *testing.T) {
40 const mac = "52:54:00:0a:0b:0c"
41 // A guest that identifies itself by a DUID gets its lease filed under one:
42 // hardware type "ff" and an opaque identifier that is not six octets. Such
43 // a stanza is not a candidate for any VM, so it can neither be returned nor
44 // displace a real match — which is the whole reason the one lease database
45 // captured on real hardware had two stanzas for one guest and still gave
46 // one unambiguous answer. The seed stops the guest asking that way at all;
47 // this is the parser's half of the same guarantee.
48 duid := "{\n\tname=web\n\tip_address=192.168.64.8" +
49 "\n\thw_address=ff,f1:f5:dd:7f:0:2:0:0:ab:11:69:49:e9:aa:ed:20:59:eb" +
50 "\n\tidentifier=ff,f1:f5:dd:7f:0:2:0:0:ab:11:69:49:e9:aa:ed:20:59:eb\n\tlease=0x6a6e5628\n}\n"
51 mine := lease("web", "192.168.64.7", "52:54:0:a:b:c", newer)
52
53 assert.Equal(t, "192.168.64.7", leaseAddress(duid+mine, mac))
54 assert.Equal(t, "192.168.64.7", leaseAddress(mine+duid, mac))
55 assert.Empty(t, leaseAddress(duid, mac), "a DUID lease belongs to no VM this backend knows")
56 }
57
58 func TestLeaseAddressTakesTheLastOfTwoCandidates(t *testing.T) {
59 const mac = "52:54:00:0a:0b:0c"
60 // Two stanzas keyed on one MAC should not happen — the guest asks under one
61 // address and the seed pins its client identifier to it. Nothing in the file
62 // ranks them if they do (`lease` is an expiry, not a grant time), so the
63 // rule is arbitrary and pinned here only so that changing it is deliberate.
64 leases := lease("web", "192.168.64.7", "52:54:0:a:b:c", older) +
65 lease("web", "192.168.64.9", "52:54:0:a:b:c", newer)
66
67 assert.Equal(t, "192.168.64.9", leaseAddress(leases, mac))
68 }
69
70 func TestLeaseAddressIsEmptyWhenTheGuestHasNotAskedYet(t *testing.T) {
71 leases := lease("other", "192.168.64.2", "52:54:0:ff:ee:dd", newer)
72
73 // Empty means "no answer", never "no address" — reconcile leaves any
74 // recorded address alone rather than clearing it.
75 assert.Empty(t, leaseAddress(leases, "52:54:00:0a:0b:0c"))
76 }
77
78 func TestLeaseAddressIgnoresAStanzaWithNoAddress(t *testing.T) {
79 leases := "{\n\tname=web\n\thw_address=1,52:54:0:a:b:c\n}\n"
80
81 assert.Empty(t, leaseAddress(leases, "52:54:00:0a:0b:0c"))
82 }
83
84 func TestLeaseAddressSurvivesAMalformedFile(t *testing.T) {
85 for name, leases := range map[string]string{
86 "empty": "",
87 "no braces": "ip_address=192.168.64.7\nhw_address=1,52:54:0:a:b:c\n",
88 "unterminated": "{\n\tip_address=192.168.64.7\n\thw_address=1,52:54:0:a:b:c\n",
89 "junk hw_address": lease("web", "192.168.64.7", "not-a-mac", newer),
90 "truncated octets": lease("web", "192.168.64.7", "52:54:0:a:b", newer),
91 // hw_address is checked exhaustively; ip_address must be too, or a
92 // stanza carrying our MAC and junk becomes the address we dial.
93 "junk ip_address": lease("web", "not-an-address", "52:54:0:a:b:c", newer),
94 "empty ip_address": lease("web", "", "52:54:0:a:b:c", newer),
95 "ip with a port": lease("web", "192.168.64.7:22", "52:54:0:a:b:c", newer),
96 } {
97 t.Run(name, func(t *testing.T) {
98 // The lease database is written by a system daemon we do not
99 // control; nothing in it may make the agent report a wrong address.
100 assert.Empty(t, leaseAddress(leases, "52:54:00:0a:0b:0c"))
101 })
102 }
103 }
104
105 func TestNormalizeMACRejectsAnythingThatIsNotSixOctets(t *testing.T) {
106 for _, in := range []string{"", "52:54:00", "52:54:00:0a:0b:0c:0d", "52-54-00-0a-0b-0c", "1,", "gg:54:00:0a:0b:0c"} {
107 assert.Empty(t, normalizeMAC(in), "%q must not normalise to anything a real MAC could equal", in)
108 }
109 // An octet past a byte is not a MAC either, however hex-looking it is.
110 assert.Empty(t, normalizeMAC("152:54:00:0a:0b:0c"))
111 }
112
113 func TestAddressReadsTheHostLeaseDatabase(t *testing.T) {
114 p := newTestProv(t, nil)
115 require.NoError(t, os.WriteFile(p.leasesPath,
116 []byte(lease("web", "192.168.64.7", trimMACZeros(state.MAC("vm-1")), newer)), 0o600))
117
118 assert.Equal(t, "192.168.64.7", p.Address("vm-1"))
119 }
120
121 func TestAddressIsEmptyBeforeAnyGuestHasLeased(t *testing.T) {
122 p := newTestProv(t, nil)
123 p.leasesPath = filepath.Join(t.TempDir(), "never-written")
124
125 // A Mac that has never run a guest has no lease database at all; that is
126 // "no answer", not a failure worth reporting up the seam.
127 assert.Empty(t, p.Address("vm-1"))
128 }
129
130 // trimMACZeros rewrites a padded MAC the way bootpd would write it, so the test
131 // fixture is the file's format rather than ours.
132 func trimMACZeros(mac string) string {
133 octets := strings.Split(mac, ":")
134 for i, o := range octets {
135 octets[i] = strings.TrimLeft(o, "0")
136 if octets[i] == "" {
137 octets[i] = "0"
138 }
139 }
140 return strings.Join(octets, ":")
141 }
internal/agent/vfkit/vfkit.go
Old New
@@ -0,0 +1,500 @@
1 // Package vfkit manages one vfkit process per VM: the macOS backend, where
2 // vfkit is the signed helper that drives Apple's Virtualization.framework.
3 // It is cloudhv's opposite number and deliberately its mirror image —
4 // argument assembly, a pidfile, a graceful stop over a unix socket with a
5 // SIGTERM fallback — because the two backends answer the same seam and a
6 // reader who knows one should recognize the other.
7 //
8 // vfkit rather than a helper of our own: Virtualization.framework is reachable
9 // only from Objective-C or Swift, so SOMETHING has to stand between the agent
10 // and the framework. vfkit is that binary already written, Apache-2.0, signed
11 // with com.apple.security.virtualization, and shipped through Homebrew — and
12 // it takes the shape the agent already spawns, one process per VM that outlives
13 // its parent. Writing our own would buy a second thing to sign and notarize.
14 //
15 // The package carries no build tag, so Linux CI proves its behavior even
16 // though only wire_darwin.go builds it into a binary. Nothing here is
17 // Darwin-only in Go terms: the platform lives in the strings (vfkit's
18 // arguments, the lease database's path), not in the syscalls.
19 package vfkit
20
21 import (
22 "bytes"
23 "context"
24 "fmt"
25 "net/http"
26 "os"
27 "os/exec"
28 "path/filepath"
29 "strconv"
30 "strings"
31 "syscall"
32
33 agentexec "github.com/a73x/eitri/internal/agent/exec"
34 "github.com/a73x/eitri/internal/agent/hostinfo"
35 "github.com/a73x/eitri/internal/agent/state"
36 )
37
38 // logMode is the file mode for vfkit's diagnostic log.
39 const logMode = 0o600
40
41 // PumpHooks is the serial-console pump lifecycle the provisioner drives
42 // (consumer-owned; the concrete implementation is *serialpump.Manager, wired
43 // by the composition root — vfkit must not import serialpump). nil disables
44 // the hooks.
45 type PumpHooks interface {
46 Ensure(vmID string)
47 Stop(vmID string)
48 }
49
50 // Provisioner manages vfkit processes for all VMs on this host.
51 //
52 // There is no Network seam here, and its absence is the design. On Linux the
53 // agent owns addressing outright — it builds the bridge, creates a tap per
54 // guest and answers DHCP itself — so cloudhv needs a collaborator to do that
55 // per VM. macOS keeps that machinery for itself: vmnet's NAT and its bootpd
56 // hand a guest its address once the guest asks, and nothing the agent can
57 // install changes the answer. So there is no per-VM host networking to
58 // lifecycle, only a lease to read (see Address).
59 type Provisioner struct {
60 st *state.Store
61 bin string // vfkit binary: a path, or a bare name resolved on $PATH
62 run agentexec.Runner
63
64 // lookPath resolves bin for Preflight. Injected so the refusal path is
65 // testable without depending on what happens to be installed on the box
66 // running the tests.
67 lookPath func(file string) (string, error)
68
69 // leasesPath is macOS's DHCP lease database. Injected for the same reason.
70 leasesPath string
71
72 // bootID identifies the host boot a VM's process was started in. It is the
73 // same reader reconcile's own boot-ID guard uses, so the two agree by
74 // construction. Injected so a test can move the host to a later boot
75 // without rebooting the box running it.
76 bootID func() string
77
78 // signal delivers a signal to a pid. Injected because the branch that
79 // matters — a kill the kernel refuses — cannot otherwise be reached without
80 // depending on what the box running the tests is allowed to signal.
81 signal func(pid int, sig syscall.Signal) error
82
83 // rest is the one client every vfkit REST call on this host goes through.
84 // One, deliberately: see newRESTClient.
85 rest *http.Client
86
87 // Pumps receives serial-pump lifecycle calls at Boot/kill. nil = no-op.
88 Pumps PumpHooks
89 }
90
91 // defaultLeasesPath is where macOS's bootpd records the addresses it has
92 // handed out. It is the whole of this backend's address knowledge.
93 const defaultLeasesPath = "/var/db/dhcpd_leases"
94
95 // New constructs a Provisioner. run may be nil when only pure methods
96 // (buildArgs) are needed.
97 func New(st *state.Store, bin string, run agentexec.Runner) *Provisioner {
98 return &Provisioner{
99 st: st,
100 bin: bin,
101 run: run,
102 lookPath: exec.LookPath,
103 leasesPath: defaultLeasesPath,
104 bootID: hostinfo.BootID,
105 signal: syscall.Kill,
106 rest: newRESTClient(),
107 }
108 }
109
110 // permanentError marks a failure no retry can fix. reconcile matches the
111 // Permanent() method structurally (errors.As against an anonymous interface),
112 // so this mirrors cloudhv's marker rather than sharing one — a shared type
113 // would be the only thing the two VMM drivers have in common.
114 type permanentError struct{ err error }
115
116 func (e permanentError) Error() string { return e.err.Error() }
117 func (e permanentError) Unwrap() error { return e.err }
118 func (e permanentError) Permanent() bool { return true }
119
120 func permanentf(format string, args ...any) error {
121 return permanentError{err: fmt.Errorf(format, args...)}
122 }
123
124 // Preflight refuses a host that has no vfkit. The one thing this backend needs
125 // beyond itself cannot be installed by the agent the way cloud-hypervisor can:
126 // vfkit must carry Apple's virtualization entitlement, and an entitlement
127 // survives only a signature we cannot produce, so a downloaded copy is not a
128 // working copy. Homebrew's is signed; that is the install path, and naming it
129 // here is the whole point of answering before the image fetch rather than
130 // after it.
131 //
132 // The refusal is permanent because the retry budget cannot fix it: three
133 // attempts against a Mac with no vfkit produce the same sentence three times.
134 // Installing vfkit does not revive the failed VM — create it again.
135 // It also refuses a state directory too deep to hold a VM's control socket.
136 // That is a property of the host's configuration, not of any one VM, and it is
137 // checked here for the same reason: the alternative is discovering it from
138 // vfkit, after the image download, in a sentence about URIs.
139 func (p *Provisioner) Preflight(_ context.Context) error {
140 if _, err := p.lookPath(p.bin); err != nil {
141 return permanentf("vfkit not found on this host (looked for %q): install it with `brew install vfkit` — "+
142 "the macOS backend runs guests through it, and it must be Apple-entitled, so the agent cannot install it itself", p.bin)
143 }
144 if sock := p.sockPath(strings.Repeat("0", vmIDLen)); len(sock) > maxSocketPath {
145 return permanentf("state directory is too deep for macOS: a VM's control socket would be %q, %d bytes against the %d-byte limit — "+
146 "run the agent with a shorter --state-dir", sock, len(sock), maxSocketPath)
147 }
148 return nil
149 }
150
151 const (
152 // maxSocketPath is macOS's cap on a unix socket path: sockaddr_un.sun_path
153 // is 104 bytes and the last one is the terminator. Linux allows 108, so a
154 // path that works in a test on Linux can still be refused on the platform
155 // this backend runs on — which is why it is checked rather than assumed.
156 maxSocketPath = 103
157
158 // vmIDLen is how long a VM id is (32 hex characters, server-minted).
159 // Preflight has to measure a VM's socket path before there is a VM, so it
160 // measures one of the right shape.
161 vmIDLen = 32
162 )
163
164 // vmFile returns a path inside the VM's directory. vfkit's per-VM artifacts
165 // are named here rather than in state.Store because they are this backend's
166 // business: the store holds what every backend has (the disk, the seed, the
167 // serial log), and a second backend's private files do not belong in a type
168 // both of them import.
169 func (p *Provisioner) vmFile(vmID, name string) string {
170 return filepath.Join(p.st.VMDir(vmID), name)
171 }
172
173 func (p *Provisioner) pidPath(vmID string) string { return p.vmFile(vmID, "vfkit.pid") }
174 func (p *Provisioner) sockPath(vmID string) string { return p.vmFile(vmID, "vfkit.sock") }
175 func (p *Provisioner) varStore(vmID string) string { return p.vmFile(vmID, "efi-vars.fd") }
176 func (p *Provisioner) logPath(vmID string) string { return p.vmFile(vmID, "vfkit.log") }
177
178 // SocketPath is the VM's vfkit REST socket, exported so the composition root
179 // can hand ConsoleSource the same path Boot writes.
180 func (p *Provisioner) SocketPath(vmID string) string { return p.sockPath(vmID) }
181
182 // disks returns the VM's block devices in attachment order: the root disk
183 // first, then the cloud-init seed. Both files must already exist when vfkit
184 // starts — it opens them while parsing its arguments, before any VM is built —
185 // which they do: reconcile prepares the disk and writes the seed before Boot.
186 //
187 // vfkit's virtio-blk has no read-only option
188 // — unlike cloud-hypervisor's — so state.Disk.ReadOnly is dropped here rather
189 // than honored. Nothing rests on it: the seed is a per-VM file, and cloud-init
190 // mounts it read-only from inside the guest regardless.
191 func (p *Provisioner) disks(spec state.VMSpec) []state.Disk {
192 return []state.Disk{
193 {Path: p.st.DiskPath(spec.VMID)},
194 {Path: p.st.SeedPath(spec.VMID), ReadOnly: true},
195 }
196 }
197
198 // buildArgs returns the vfkit command line for spec. It is pure — createVarStore
199 // is passed in rather than stat'ed here — so the whole argument contract is
200 // unit-testable without a filesystem or a Mac.
201 func (p *Provisioner) buildArgs(spec state.VMSpec, createVarStore bool) []string {
202 vmID := spec.VMID
203
204 // The EFI variable store is the guest's NVRAM, and `create` initializes a
205 // fresh one — which is why it is conditional. Ubuntu writes its boot entry
206 // there on first boot; recreating the store every launch would throw that
207 // away each time and leave the guest booting only by the removable-media
208 // fallback path. Create it exactly once, on the boot that has no store yet.
209 bootloader := "efi,variable-store=" + p.varStore(vmID)
210 if createVarStore {
211 bootloader += ",create"
212 }
213
214 args := []string{
215 // The REST socket is how Shutdown asks for an ACPI power-down and how
216 // the console finds its PTY. Without it vfkit exposes neither.
217 "--restful-uri", "unix://" + p.sockPath(vmID),
218 "--cpus", strconv.FormatInt(spec.VCPUs, 10),
219 "--memory", strconv.FormatInt(spec.MemMB, 10),
220 "--bootloader", bootloader,
221 }
222 for _, d := range p.disks(spec) {
223 args = append(args, "--device", "virtio-blk,path="+d.Path)
224 }
225 return append(args,
226 // nat is Apple's vmnet-shared network: the host NATs the guest out and
227 // its bootpd answers the guest's DHCP. mac is the stickiness key — it
228 // is what makes the lease, and therefore the address, the same one
229 // across a rebuild (see Address).
230 "--device", "virtio-net,nat,mac="+state.MAC(vmID),
231 // pty, not logFilePath: the console has to carry keystrokes back to the
232 // guest, and a log file is one-way. The pump opens the far end.
233 "--device", "virtio-serial,pty",
234 // cloud-hypervisor gives a guest virtio-rng without being asked; vfkit
235 // does not, and a guest short of entropy stalls in early boot.
236 "--device", "virtio-rng",
237 )
238 }
239
240 // maxDiskGB caps a VM disk at 1 PiB (2^20 GiB) — far beyond any real host,
241 // and small enough that DiskGB<<30 can never overflow int64 (2^50 max).
242 const maxDiskGB = 1 << 20
243
244 // PrepareRootDisk creates the VM's root disk by cloning basePath on APFS and
245 // growing it to spec.DiskGB gigabytes.
246 //
247 // The shrink guard is cloudhv's, restated rather than shared: truncating to an
248 // EXACT size means a target below the base image would chop the guest
249 // filesystem, and that is true of any backend that grows a cloned image. The
250 // duplication is deliberate for now — hoisting the policy into reconcile is
251 // the right fix, and is a change to the shared engine, not to this backend.
252 func (p *Provisioner) PrepareRootDisk(ctx context.Context, spec state.VMSpec, basePath string) error {
253 base, err := os.Stat(basePath)
254 if err != nil {
255 return fmt.Errorf("stat base image %s: %w", basePath, err)
256 }
257 if spec.DiskGB < 1 || spec.DiskGB > maxDiskGB {
258 return permanentf("disk_gb %d out of range [1, %d]", spec.DiskGB, int64(maxDiskGB))
259 }
260 targetBytes := spec.DiskGB << 30
261 if targetBytes < base.Size() {
262 return permanentf("disk_gb %d (%d bytes) is smaller than base image %s (%d bytes) — shrinking would corrupt the guest",
263 spec.DiskGB, targetBytes, basePath, base.Size())
264 }
265 if err := os.MkdirAll(p.st.VMDir(spec.VMID), 0o700); err != nil {
266 return fmt.Errorf("mkdir %s: %w", p.st.VMDir(spec.VMID), err)
267 }
268 // Build into a sibling temp file and rename into place so a create killed
269 // mid-copy can never leave a torn disk.raw at the final path. Sibling, so
270 // the clone stays on one filesystem and the rename is atomic.
271 diskPath := p.st.DiskPath(spec.VMID)
272 tmpPath := diskPath + ".partial"
273 _ = os.Remove(tmpPath)
274 if err := p.clone(ctx, basePath, tmpPath); err != nil {
275 _ = os.Remove(tmpPath)
276 return err
277 }
278 // Grown in-process rather than by truncate(1): macOS does not ship one.
279 // os.Truncate grows sparsely, exactly as GNU truncate does on Linux.
280 if err := os.Truncate(tmpPath, targetBytes); err != nil {
281 _ = os.Remove(tmpPath)
282 return fmt.Errorf("grow %s to %dG: %w", tmpPath, spec.DiskGB, err)
283 }
284 if err := os.Rename(tmpPath, diskPath); err != nil {
285 _ = os.Remove(tmpPath)
286 return fmt.Errorf("rename %s -> %s: %w", tmpPath, diskPath, err)
287 }
288 return nil
289 }
290
291 // clone copies src to dst, sharing blocks where the filesystem can. `cp -c`
292 // is APFS's clonefile — the reflink analog — but unlike `cp --reflink=auto` it
293 // FAILS rather than degrading when the filesystem cannot clone, so the fallback
294 // is spelled out here. A Mac's boot volume is APFS; a state directory on an
295 // external HFS+ disk is the case that needs the second attempt.
296 func (p *Provisioner) clone(ctx context.Context, src, dst string) error {
297 if _, err := p.run(ctx, "cp", "-c", src, dst); err == nil {
298 return nil
299 }
300 _ = os.Remove(dst) // a failed clone may have left a partial file in the way
301 if _, err := p.run(ctx, "cp", src, dst); err != nil {
302 return fmt.Errorf("cp %s %s: %w", src, dst, err)
303 }
304 return nil
305 }
306
307 // Boot spawns a vfkit process for spec. The process is placed in its own
308 // session (Setsid) so it survives an agent restart, and ctx is deliberately NOT
309 // wired to it: tying a guest's lifetime to the agent's would power off every VM
310 // on a graceful agent stop. Stopping a VM is Shutdown/Destroy's job alone.
311 func (p *Provisioner) Boot(_ context.Context, vmID string, spec state.VMSpec) error {
312 // vfkit removes its socket on a clean exit only, so a crash, a SIGKILL or a
313 // host reboot leaves one behind that the next bind would trip over.
314 _ = os.Remove(p.sockPath(vmID))
315
316 // Anything short of a successful stat means "initialise a store". Matching
317 // ENOENT alone read every other stat failure — EACCES on a state dir whose
318 // ownership moved, EIO on a sick disk — as proof the store is there, which
319 // is the one thing a failed stat cannot establish. Contents are not judged
320 // and cannot be: a truncated store stats perfectly well, and the firmware is
321 // the only reader that would know. A VM whose NVRAM is unreadable is rebuilt
322 // under a new id and a new directory, which is where it gets a fresh one.
323 _, err := os.Stat(p.varStore(vmID))
324 args := p.buildArgs(spec, err != nil)
325
326 cmd := exec.Command(p.bin, args...)
327 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
328
329 // Guest console output goes to the PTY the pump drains. vfkit's own
330 // diagnostics — startup errors, the framework's complaints — go here.
331 vfLog, err := os.OpenFile(p.logPath(vmID), os.O_CREATE|os.O_APPEND|os.O_WRONLY, logMode)
332 if err != nil {
333 return fmt.Errorf("open vfkit.log %s: %w", vmID, err)
334 }
335 cmd.Stdout = vfLog
336 cmd.Stderr = vfLog
337
338 if err := cmd.Start(); err != nil {
339 _ = vfLog.Close()
340 return fmt.Errorf("vfkit start %s: %w", vmID, err)
341 }
342 _ = vfLog.Close() // the child holds its own copy
343
344 if err := os.WriteFile(p.pidPath(vmID), p.pidRecord(cmd.Process.Pid), 0o600); err != nil {
345 // Best effort: kill the orphan we can no longer track, and Wait to reap
346 // it — the async reaper below is not started on this path.
347 _ = cmd.Process.Kill()
348 _ = cmd.Wait()
349 return fmt.Errorf("write pidfile %s: %w", vmID, err)
350 }
351
352 go func() { _ = cmd.Wait() }() // reap; exit status is not ours to judge
353
354 // Start the pump now so the boot log lands in the ring from as close to
355 // power-on as possible. Its first few Opens will fail — the PTY does not
356 // exist until vfkit has built the VM — and the pump's reconnect loop is
357 // exactly the retry for that.
358 if p.Pumps != nil {
359 p.Pumps.Ensure(vmID)
360 }
361 return nil
362 }
363
364 // pidRecord renders the pidfile: the process id, and the host boot it was
365 // started in. The boot id is there because the pid alone is not evidence of
366 // anything after a reboot — see ownedPID.
367 func (p *Provisioner) pidRecord(pid int) []byte {
368 return []byte(strconv.Itoa(pid) + "\n" + p.bootID() + "\n")
369 }
370
371 // ownedPID returns the pid of the vfkit process THIS agent started for vmID, or
372 // 0 when there is none it may signal.
373 //
374 // The boot id is what makes that a real answer rather than a hope. The state
375 // directory survives a reboot, macOS recycles pids out of a small space, and
376 // this backend's host is a laptop that reboots most days — so a pidfile written
377 // before the last boot names whatever now happens to hold that number. Reading
378 // it as ours makes Running() report a dead VM as up, and, on the reap path,
379 // SIGTERMs and then SIGKILLs an unrelated process on the user's machine.
380 // reconcile's own boot-id guard does not cover this: reapVM reaches Shutdown
381 // before any of that reasoning runs.
382 //
383 // A pidfile with no boot id at all is treated as ours. It can only have been
384 // written by an agent that predates this format, and the cheaper-looking answer
385 // — refuse, report not running — is the more expensive one: reconcile reads a
386 // live VM as lost and boots a SECOND vfkit onto the same disk image, and two
387 // hypervisors writing one disk corrupt the guest. The window is transitional,
388 // since the next Boot rewrites the pidfile in this format.
389 func (p *Provisioner) ownedPID(vmID string) int {
390 raw, err := os.ReadFile(p.pidPath(vmID))
391 if err != nil {
392 return 0
393 }
394 line, rest, _ := strings.Cut(string(raw), "\n")
395 pid, err := strconv.Atoi(strings.TrimSpace(line))
396 if err != nil {
397 return 0
398 }
399 if boot := strings.TrimSpace(rest); boot != "" && boot != p.bootID() {
400 return 0
401 }
402 return pid
403 }
404
405 // Running reports whether the vfkit process for vmID is still alive, by
406 // pidfile and signal 0.
407 //
408 // PID-liveness within this host boot: a pidfile from an earlier boot reports
409 // not-running outright (see ownedPID), and within one boot a pid this agent
410 // wrote cannot have been recycled while its process is alive. reconcile's
411 // boot-ID check remains the authoritative reboot guard for the VM's record.
412 func (p *Provisioner) Running(vmID string) bool {
413 pid := p.ownedPID(vmID)
414 if pid == 0 {
415 return false
416 }
417 return p.signal(pid, 0) == nil
418 }
419
420 // Shutdown asks vfkit for a graceful stop — the framework's ACPI power-down,
421 // the same request cloud-hypervisor's power-button API makes. Falls back to
422 // SIGTERM when the socket is gone or refuses, which covers a vfkit that is
423 // unhealthy or a VM that is not running.
424 func (p *Provisioner) Shutdown(ctx context.Context, vmID string) error {
425 body := bytes.NewReader([]byte(`{"state":"Stop"}`))
426 req, err := http.NewRequestWithContext(withSocket(ctx, p.sockPath(vmID)), http.MethodPost, "http://vfkit/vm/state", body)
427 if err != nil {
428 return p.sigterm(vmID)
429 }
430 req.Header.Set("Content-Type", "application/json")
431 resp, err := p.rest.Do(req)
432 if err != nil {
433 return p.sigterm(vmID)
434 }
435 resp.Body.Close()
436 // vfkit answers 202 Accepted. Anything past 2xx means the stop did not
437 // happen — a 400 for a VM in the wrong state, a 500 from the framework.
438 if resp.StatusCode >= 300 {
439 return p.sigterm(vmID)
440 }
441 return nil
442 }
443
444 // sigterm sends SIGTERM to the process identified by vmID's PID file.
445 func (p *Provisioner) sigterm(vmID string) error {
446 pid := p.ownedPID(vmID)
447 if pid == 0 {
448 return nil // already gone
449 }
450 if err := p.signal(pid, syscall.SIGTERM); err != nil && err != syscall.ESRCH {
451 return fmt.Errorf("SIGTERM %s (pid %d): %w", vmID, pid, err)
452 }
453 return nil
454 }
455
456 // Destroy stops the VM and releases every host resource it holds: the vfkit
457 // process, its serial pump, its REST socket.
458 //
459 // The kill's error is propagated, exactly as cloudhv propagates the failure to
460 // delete a VM's tap, and for the same reason: nil is the promise reconcile
461 // deletes the record on the strength of, and the record is the only thing on
462 // this host that names the process. A SIGKILL the kernel refused has not proved
463 // the process gone, so answering nil would strand a live vfkit holding an
464 // unlinked disk, a vmnet attachment and a DHCP lease, with nothing left to find
465 // it by. A non-nil answer keeps the record and the next tick tries again.
466 //
467 // This backend has less to release than cloudhv does: it created no host
468 // networking, because vmnet's NAT is host-wide and outlives every guest. The
469 // guest's DHCP lease outlives it too, in a root-owned file bootpd ages out on
470 // its own; because the lease is keyed on the VM's deterministic MAC, a rebuilt
471 // VM reclaims the same address rather than leaking a new one.
472 func (p *Provisioner) Destroy(_ context.Context, vmID string) error {
473 return p.kill(vmID)
474 }
475
476 // kill SIGKILLs the vfkit process for vmID, stops its serial pump and removes
477 // the pidfile and REST socket. It takes no context: killing a process by
478 // pidfile is a syscall, and pretending otherwise made callers think a cancelled
479 // context could skip a teardown.
480 func (p *Provisioner) kill(vmID string) error {
481 // Pump teardown first: a pump leaked past a failed kill would reopen a PTY
482 // whose far end is gone, forever.
483 if p.Pumps != nil {
484 p.Pumps.Stop(vmID)
485 }
486 // A pid this agent may not claim — none recorded, or one from an earlier
487 // host boot — leaves nothing to kill and nothing to keep the record for.
488 pid := p.ownedPID(vmID)
489 if pid != 0 {
490 if err := p.signal(pid, syscall.SIGKILL); err != nil && err != syscall.ESRCH {
491 // Keep the pidfile and report: a SIGKILL that failed with anything
492 // but ESRCH has not proved the process gone, and the pidfile is the
493 // only record of which process it is.
494 return fmt.Errorf("SIGKILL %s (pid %d): %w", vmID, pid, err)
495 }
496 }
497 _ = os.Remove(p.pidPath(vmID))
498 _ = os.Remove(p.sockPath(vmID))
499 return nil
500 }
internal/agent/vfkit/vfkit_test.go
Old New
@@ -0,0 +1,655 @@
1 package vfkit
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "net/http"
9 "os"
10 "path/filepath"
11 "strconv"
12 "strings"
13 "syscall"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/require"
19
20 agentexec "github.com/a73x/eitri/internal/agent/exec"
21 "github.com/a73x/eitri/internal/agent/state"
22 )
23
24 // testSpec is the VM every test provisions unless it needs otherwise.
25 func testSpec() state.VMSpec {
26 return state.VMSpec{VMID: "vm-1", Name: "web", VCPUs: 2, MemMB: 2048, DiskGB: 10}
27 }
28
29 func newTestProv(t *testing.T, run agentexec.Runner) *Provisioner {
30 t.Helper()
31 st, err := state.Open(t.TempDir())
32 require.NoError(t, err)
33 p := New(st, "vfkit", run)
34 // Never let a test read the real host's lease database.
35 p.leasesPath = filepath.Join(t.TempDir(), "dhcpd_leases")
36 return p
37 }
38
39 // isPermanent reports whether err carries the marker reconcile terminal-fails on.
40 func isPermanent(err error) bool {
41 var perm interface{ Permanent() bool }
42 return errors.As(err, &perm) && perm.Permanent()
43 }
44
45 func TestBuildArgsIsTheVfkitContract(t *testing.T) {
46 p := newTestProv(t, nil)
47 vmDir := p.st.VMDir("vm-1")
48
49 args := p.buildArgs(testSpec(), true)
50
51 assert.Equal(t, []string{
52 "--restful-uri", "unix://" + filepath.Join(vmDir, "vfkit.sock"),
53 "--cpus", "2",
54 "--memory", "2048",
55 "--bootloader", "efi,variable-store=" + filepath.Join(vmDir, "efi-vars.fd") + ",create",
56 "--device", "virtio-blk,path=" + filepath.Join(vmDir, "disk.raw"),
57 "--device", "virtio-blk,path=" + filepath.Join(vmDir, "seed.iso"),
58 "--device", "virtio-net,nat,mac=" + state.MAC("vm-1"),
59 "--device", "virtio-serial,pty",
60 "--device", "virtio-rng",
61 }, args)
62 }
63
64 func TestBuildArgsPutsTheRootDiskFirst(t *testing.T) {
65 p := newTestProv(t, nil)
66 args := p.buildArgs(testSpec(), false)
67
68 var blk []string
69 for i, a := range args {
70 if strings.HasPrefix(a, "virtio-blk,") {
71 blk = append(blk, a)
72 require.Equal(t, "--device", args[i-1])
73 }
74 }
75 require.Len(t, blk, 2)
76 // vfkit maps block devices to /dev/vd* by argument position, so the guest
77 // boots from whichever comes first. The seed overtaking the root disk would
78 // be silent until the guest failed to boot.
79 assert.Contains(t, blk[0], "disk.raw", "the root disk must be the first block device")
80 assert.Contains(t, blk[1], "seed.iso")
81 }
82
83 func TestBuildArgsCreatesTheVariableStoreOnlyWhenThereIsNone(t *testing.T) {
84 p := newTestProv(t, nil)
85
86 assert.Contains(t, strings.Join(p.buildArgs(testSpec(), true), " "), "efi-vars.fd,create")
87 // A second boot must NOT recreate it: the store is the guest's NVRAM, and
88 // re-initialising it throws away the boot entry the guest wrote there.
89 assert.NotContains(t, strings.Join(p.buildArgs(testSpec(), false), " "), ",create")
90 }
91
92 func TestPreflightRefusesAHostWithoutVfkit(t *testing.T) {
93 p := newTestProv(t, nil)
94 p.lookPath = func(string) (string, error) { return "", errors.New("executable file not found in $PATH") }
95
96 err := p.Preflight(context.Background())
97
98 require.Error(t, err)
99 assert.True(t, isPermanent(err), "no retry installs a signed binary — the refusal must be terminal")
100 assert.Contains(t, err.Error(), "brew install vfkit", "the refusal must name the fix")
101 }
102
103 func TestPreflightPassesWhenVfkitResolves(t *testing.T) {
104 p := newTestProv(t, nil)
105 p.lookPath = func(file string) (string, error) { return "/opt/homebrew/bin/" + file, nil }
106
107 assert.NoError(t, p.Preflight(context.Background()))
108 }
109
110 func TestPreflightRefusesAStateDirectoryTooDeepForASocket(t *testing.T) {
111 deep := filepath.Join(t.TempDir(), strings.Repeat("d", maxSocketPath))
112 require.NoError(t, os.MkdirAll(deep, 0o700))
113 st, err := state.Open(deep)
114 require.NoError(t, err)
115 p := New(st, "vfkit", nil)
116 p.lookPath = func(file string) (string, error) { return file, nil }
117
118 // macOS caps a unix socket path at 104 bytes. Left to vfkit, this surfaces
119 // as a URI complaint after the image download; here it is the host's own
120 // verdict, before anything is spent.
121 err = p.Preflight(context.Background())
122
123 require.Error(t, err)
124 assert.True(t, isPermanent(err), "a state directory does not get shorter on retry")
125 assert.Contains(t, err.Error(), "--state-dir", "the refusal must name the fix")
126 }
127
128 func TestPreflightAcceptsTheDefaultStateDirectory(t *testing.T) {
129 // Not t.TempDir(): its paths carry the test's own name and are long enough
130 // to trip the guard — which is a fair demonstration that the guard bites.
131 dir, err := os.MkdirTemp("", "e")
132 require.NoError(t, err)
133 t.Cleanup(func() { _ = os.RemoveAll(dir) })
134 st, err := state.Open(dir)
135 require.NoError(t, err)
136 p := New(st, "vfkit", nil)
137 p.lookPath = func(file string) (string, error) { return file, nil }
138
139 assert.NoError(t, p.Preflight(context.Background()))
140 // The shipped default is /var/lib/eitri-agent; a VM's socket under it is
141 // 68 bytes, so the guard must not be so tight that normal use trips it.
142 assert.Less(t, len("/var/lib/eitri-agent/vms/"+strings.Repeat("0", vmIDLen)+"/vfkit.sock"), maxSocketPath)
143 }
144
145 // fakeRunner records the commands it is asked to run and answers from a script
146 // of per-command results, so disk preparation is testable without a Mac.
147 type fakeRunner struct {
148 calls [][]string
149 // fail returns an error for the given argv, or nil to let the call succeed.
150 fail func(argv []string) error
151 }
152
153 func (f *fakeRunner) run(_ context.Context, name string, args ...string) (string, error) {
154 argv := append([]string{name}, args...)
155 f.calls = append(f.calls, argv)
156 if f.fail != nil {
157 if err := f.fail(argv); err != nil {
158 return "", err
159 }
160 }
161 // A real cp produces the destination; tests that inspect the disk need it.
162 if len(argv) >= 2 {
163 src, dst := argv[len(argv)-2], argv[len(argv)-1]
164 if data, err := os.ReadFile(src); err == nil {
165 _ = os.WriteFile(dst, data, 0o600)
166 }
167 }
168 return "", nil
169 }
170
171 func writeBaseImage(t *testing.T, size int) string {
172 t.Helper()
173 path := filepath.Join(t.TempDir(), "base.raw")
174 require.NoError(t, os.WriteFile(path, make([]byte, size), 0o600))
175 return path
176 }
177
178 func TestPrepareRootDiskClonesTheBaseImageAndGrowsIt(t *testing.T) {
179 r := &fakeRunner{}
180 p := newTestProv(t, r.run)
181 base := writeBaseImage(t, 4096)
182
183 require.NoError(t, p.PrepareRootDisk(context.Background(), testSpec(), base))
184
185 require.Len(t, r.calls, 1, "an APFS clone is one command, not a copy loop")
186 assert.Equal(t, []string{"cp", "-c", base, p.st.DiskPath("vm-1") + ".partial"}, r.calls[0])
187
188 st, err := os.Stat(p.st.DiskPath("vm-1"))
189 require.NoError(t, err, "the disk must be renamed into place, not left as .partial")
190 assert.Equal(t, int64(10)<<30, st.Size())
191 _, err = os.Stat(p.st.DiskPath("vm-1") + ".partial")
192 assert.True(t, os.IsNotExist(err), "no temp file may survive a successful prepare")
193 }
194
195 func TestPrepareRootDiskFallsBackToAPlainCopyOffAPFS(t *testing.T) {
196 r := &fakeRunner{fail: func(argv []string) error {
197 if len(argv) > 1 && argv[1] == "-c" {
198 return errors.New("cp: clonefile failed: Operation not supported")
199 }
200 return nil
201 }}
202 p := newTestProv(t, r.run)
203 base := writeBaseImage(t, 4096)
204
205 // `cp -c` errors rather than degrading when the filesystem cannot clone, so
206 // a state directory on a non-APFS volume must still produce a disk.
207 require.NoError(t, p.PrepareRootDisk(context.Background(), testSpec(), base))
208
209 require.Len(t, r.calls, 2)
210 assert.Equal(t, "-c", r.calls[0][1])
211 assert.Equal(t, []string{"cp", base, p.st.DiskPath("vm-1") + ".partial"}, r.calls[1])
212 st, err := os.Stat(p.st.DiskPath("vm-1"))
213 require.NoError(t, err)
214 assert.Equal(t, int64(10)<<30, st.Size())
215 }
216
217 func TestPrepareRootDiskReportsACopyThatFailedBothWays(t *testing.T) {
218 // A real cp writes what it can before it runs out of space, so the failure
219 // leaves a partial file behind — which is the only state in which the
220 // cleanup that follows it does any work.
221 r := &fakeRunner{fail: func(argv []string) error {
222 require.NoError(t, os.WriteFile(argv[len(argv)-1], make([]byte, 512), 0o600))
223 return errors.New("No space left on device")
224 }}
225 p := newTestProv(t, r.run)
226
227 err := p.PrepareRootDisk(context.Background(), testSpec(), writeBaseImage(t, 4096))
228
229 require.Error(t, err)
230 assert.False(t, isPermanent(err), "a full disk is retryable — the operator can free space")
231 _, statErr := os.Stat(p.st.DiskPath("vm-1"))
232 assert.True(t, os.IsNotExist(statErr), "a failed prepare must leave no disk behind")
233 // The temp file matters as much as the final path: it sits on the same
234 // filesystem the copy just filled, and a create that retries every tick
235 // would otherwise strand a fresh multi-gigabyte carcass each time.
236 _, statErr = os.Stat(p.st.DiskPath("vm-1") + ".partial")
237 assert.True(t, os.IsNotExist(statErr), "a failed prepare must leave no temp file behind")
238 }
239
240 func TestPrepareRootDiskRefusesToShrinkTheBaseImage(t *testing.T) {
241 r := &fakeRunner{}
242 p := newTestProv(t, r.run)
243 spec := testSpec()
244 spec.DiskGB = 1
245
246 // Growing is a truncate to an EXACT size, so a target under the base image
247 // would chop the guest filesystem rather than fitting it.
248 err := p.PrepareRootDisk(context.Background(), spec, writeBaseImage(t, 2<<30))
249
250 require.Error(t, err)
251 assert.True(t, isPermanent(err), "a spec that cannot hold its image is not fixed by retrying")
252 assert.Empty(t, r.calls, "nothing may be copied for a disk that cannot be built")
253 }
254
255 func TestPrepareRootDiskRejectsAnOutOfRangeSize(t *testing.T) {
256 base := writeBaseImage(t, 4096)
257 for _, gb := range []int64{0, -1, maxDiskGB + 1} {
258 t.Run(strconv.FormatInt(gb, 10), func(t *testing.T) {
259 r := &fakeRunner{}
260 p := newTestProv(t, r.run)
261 spec := testSpec()
262 spec.DiskGB = gb
263
264 err := p.PrepareRootDisk(context.Background(), spec, base)
265
266 require.Error(t, err)
267 assert.True(t, isPermanent(err))
268 // The range check must run BEFORE the byte computation: DiskGB<<30
269 // wraps to a small positive number for sizes past 2^33 GiB, and a
270 // wrapped value would sail through the shrink guard.
271 assert.Empty(t, r.calls)
272 })
273 }
274 }
275
276 // fakePumps records the serial-pump lifecycle calls the provisioner drives.
277 type fakePumps struct{ ensured, stopped []string }
278
279 func (f *fakePumps) Ensure(vmID string) { f.ensured = append(f.ensured, vmID) }
280 func (f *fakePumps) Stop(vmID string) { f.stopped = append(f.stopped, vmID) }
281
282 // argRecorder writes a fake vfkit that records its argv and then sleeps, so
283 // Boot's whole chain — stale-socket cleanup, argument assembly, spawn, pidfile,
284 // pump — is observable without a hypervisor.
285 func argRecorder(t *testing.T) (bin, argsFile string) {
286 t.Helper()
287 dir := t.TempDir()
288 bin, argsFile = filepath.Join(dir, "fake-vfkit"), filepath.Join(dir, "argv")
289 script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %s\nexec sleep 30\n", argsFile)
290 require.NoError(t, os.WriteFile(bin, []byte(script), 0o700))
291 return bin, argsFile
292 }
293
294 func TestBootSpawnsVfkitAndTracksIt(t *testing.T) {
295 bin, argsFile := argRecorder(t)
296 p := newTestProv(t, nil)
297 p.bin = bin
298 pumps := &fakePumps{}
299 p.Pumps = pumps
300 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
301 t.Cleanup(func() { _ = p.kill("vm-1") })
302
303 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
304
305 assert.True(t, p.Running("vm-1"), "a booted VM must be tracked by its pidfile")
306 assert.Equal(t, []string{"vm-1"}, pumps.ensured,
307 "the pump must start at power-on or the boot log is lost before anyone attaches")
308
309 var argv []byte
310 require.Eventually(t, func() bool {
311 var err error
312 argv, err = os.ReadFile(argsFile)
313 return err == nil && len(argv) > 0
314 }, 5*time.Second, 20*time.Millisecond, "the fake vfkit never recorded its arguments")
315 assert.Contains(t, string(argv), "efi-vars.fd,create",
316 "the first boot of a VM must create its EFI variable store")
317
318 _, err := os.Stat(p.logPath("vm-1"))
319 assert.NoError(t, err, "vfkit's own diagnostics must land in a log, not the void")
320 }
321
322 func TestBootClearsAStaleSocket(t *testing.T) {
323 bin, _ := argRecorder(t)
324 p := newTestProv(t, nil)
325 p.bin = bin
326 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
327 // vfkit removes its socket on a clean exit only, so a crash or a host reboot
328 // leaves one that the next bind would trip over.
329 require.NoError(t, os.WriteFile(p.sockPath("vm-1"), []byte("stale"), 0o600))
330 t.Cleanup(func() { _ = p.kill("vm-1") })
331
332 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
333
334 data, err := os.ReadFile(p.sockPath("vm-1"))
335 if err == nil {
336 assert.NotEqual(t, "stale", string(data), "the stale socket must be gone before vfkit binds")
337 }
338 }
339
340 func TestBootReportsAVfkitThatCannotStart(t *testing.T) {
341 p := newTestProv(t, nil)
342 p.bin = filepath.Join(t.TempDir(), "not-installed")
343 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
344
345 err := p.Boot(context.Background(), "vm-1", testSpec())
346
347 require.Error(t, err)
348 assert.Contains(t, err.Error(), "vfkit start")
349 assert.False(t, p.Running("vm-1"))
350 }
351
352 func TestRunningIsFalseForAVMThatWasNeverBooted(t *testing.T) {
353 p := newTestProv(t, nil)
354 assert.False(t, p.Running("vm-1"))
355 }
356
357 func TestRunningIsFalseAfterTheProcessIsGone(t *testing.T) {
358 p := newTestProv(t, nil)
359 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
360 // A pid that cannot be running: the kernel rejects it outright.
361 require.NoError(t, os.WriteFile(p.pidPath("vm-1"), []byte("2147483647"), 0o600))
362
363 assert.False(t, p.Running("vm-1"))
364 }
365
366 // serveREST starts an HTTP server on vmID's vfkit socket path and returns the
367 // requests it received.
368 func serveREST(t *testing.T, p *Provisioner, vmID string, h http.HandlerFunc) *[]*http.Request {
369 t.Helper()
370 require.NoError(t, os.MkdirAll(p.st.VMDir(vmID), 0o700))
371 ln, err := net.Listen("unix", p.sockPath(vmID))
372 require.NoError(t, err)
373 var got []*http.Request
374 srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
375 got = append(got, r)
376 h(w, r)
377 })}
378 go func() { _ = srv.Serve(ln) }()
379 t.Cleanup(func() { _ = srv.Close() })
380 return &got
381 }
382
383 func TestShutdownAsksVfkitForAGracefulStop(t *testing.T) {
384 p := newTestProv(t, nil)
385 var body string
386 got := serveREST(t, p, "vm-1", func(w http.ResponseWriter, r *http.Request) {
387 b := make([]byte, 64)
388 n, _ := r.Body.Read(b)
389 body = string(b[:n])
390 w.WriteHeader(http.StatusAccepted) // what vfkit answers
391 })
392
393 require.NoError(t, p.Shutdown(context.Background(), "vm-1"))
394
395 require.Len(t, *got, 1)
396 assert.Equal(t, http.MethodPost, (*got)[0].Method)
397 assert.Equal(t, "/vm/state", (*got)[0].URL.Path)
398 // "Stop" is the ACPI power-down; "HardStop" would cut the power instead,
399 // which is Destroy's job, not Shutdown's.
400 assert.JSONEq(t, `{"state":"Stop"}`, body)
401 }
402
403 func TestShutdownFallsBackToSigtermWhenVfkitRefuses(t *testing.T) {
404 p := newTestProv(t, nil)
405 serveREST(t, p, "vm-1", func(w http.ResponseWriter, _ *http.Request) {
406 w.WriteHeader(http.StatusInternalServerError)
407 })
408 // A pid the signal cannot reach stands in for a VM whose process is gone:
409 // the fallback must report success, not an error about a dead process.
410 require.NoError(t, os.WriteFile(p.pidPath("vm-1"), []byte("2147483647"), 0o600))
411
412 assert.NoError(t, p.Shutdown(context.Background(), "vm-1"))
413 }
414
415 func TestShutdownOfAVMWithNoProcessIsNotAnError(t *testing.T) {
416 p := newTestProv(t, nil)
417 // No socket, no pidfile: reconcile calls Shutdown on every tick past a stop
418 // request, and a VM that is already down must not fail the pass.
419 assert.NoError(t, p.Shutdown(context.Background(), "vm-1"))
420 }
421
422 func TestDestroyKillsTheProcessAndStopsThePump(t *testing.T) {
423 bin, _ := argRecorder(t)
424 p := newTestProv(t, nil)
425 p.bin = bin
426 pumps := &fakePumps{}
427 p.Pumps = pumps
428 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
429 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
430 pid := p.ownedPID("vm-1")
431 require.NotZero(t, pid)
432
433 // nil is the promise reconcile deletes the VM's record on the strength of,
434 // so it may only follow a kill that actually happened.
435 require.NoError(t, p.Destroy(context.Background(), "vm-1"))
436
437 assert.Equal(t, []string{"vm-1"}, pumps.stopped)
438 assert.Eventually(t, func() bool { return syscall.Kill(pid, 0) != nil },
439 5*time.Second, 20*time.Millisecond, "the vfkit process outlived Destroy")
440 _, err := os.Stat(p.pidPath("vm-1"))
441 assert.True(t, os.IsNotExist(err), "the pidfile must not outlive the process it names")
442 }
443
444 func TestDestroyOfAVMThatWasNeverBootedIsNotAnError(t *testing.T) {
445 p := newTestProv(t, nil)
446 // Destroy is called on every tick past a VM's grace until it returns nil.
447 assert.NoError(t, p.Destroy(context.Background(), "vm-1"))
448 assert.NoError(t, p.Destroy(context.Background(), "vm-1"))
449 }
450
451 // writePidfile puts a pidfile in place without booting anything, so the paths
452 // that read one can be driven to any state a real host can be in.
453 func writePidfile(t *testing.T, p *Provisioner, vmID, contents string) {
454 t.Helper()
455 require.NoError(t, os.MkdirAll(p.st.VMDir(vmID), 0o700))
456 require.NoError(t, os.WriteFile(p.pidPath(vmID), []byte(contents), 0o600))
457 }
458
459 func TestDestroyReportsAKillTheKernelRefused(t *testing.T) {
460 p := newTestProv(t, nil)
461 p.signal = func(int, syscall.Signal) error { return syscall.EPERM }
462 writePidfile(t, p, "vm-1", "4321\n"+p.bootID()+"\n")
463
464 err := p.Destroy(context.Background(), "vm-1")
465
466 // A refused SIGKILL has not proved the process gone. reconcile deletes the
467 // VM's whole directory the moment Destroy answers nil, so answering nil
468 // here would take the pidfile — the only record of a live vfkit still
469 // holding an unlinked disk and a vmnet attachment — with it.
470 require.Error(t, err)
471 assert.Contains(t, err.Error(), "SIGKILL")
472 _, statErr := os.Stat(p.pidPath("vm-1"))
473 assert.NoError(t, statErr, "the record of a process we could not kill must survive")
474 }
475
476 func TestDestroyTreatsAProcessThatIsAlreadyGoneAsDone(t *testing.T) {
477 p := newTestProv(t, nil)
478 p.signal = func(int, syscall.Signal) error { return syscall.ESRCH }
479 writePidfile(t, p, "vm-1", "4321\n"+p.bootID()+"\n")
480
481 // ESRCH is the answer that proves the kill's whole purpose is served.
482 require.NoError(t, p.Destroy(context.Background(), "vm-1"))
483 _, statErr := os.Stat(p.pidPath("vm-1"))
484 assert.True(t, os.IsNotExist(statErr))
485 }
486
487 func TestBootRecordsTheHostBootAlongsideThePID(t *testing.T) {
488 bin, _ := argRecorder(t)
489 p := newTestProv(t, nil)
490 p.bin = bin
491 p.bootID = func() string { return "boot-a" }
492 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
493 t.Cleanup(func() { _ = p.kill("vm-1") })
494
495 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
496
497 raw, err := os.ReadFile(p.pidPath("vm-1"))
498 require.NoError(t, err)
499 assert.Contains(t, string(raw), "boot-a", "a pid without the boot it belongs to is not evidence of anything")
500 }
501
502 func TestAPidFromAnEarlierBootIsNotOurs(t *testing.T) {
503 p := newTestProv(t, nil)
504 p.bootID = func() string { return "boot-b" }
505 var signalled []int
506 p.signal = func(pid int, _ syscall.Signal) error { signalled = append(signalled, pid); return nil }
507 // A pidfile written before the last reboot. macOS recycles pids out of a
508 // small space and this backend's host is a laptop, so the number now names
509 // whatever the user happens to be running.
510 writePidfile(t, p, "vm-1", "4321\nboot-a\n")
511
512 assert.False(t, p.Running("vm-1"), "a VM whose process died with the host is not running")
513 assert.NoError(t, p.Shutdown(context.Background(), "vm-1"))
514 assert.NoError(t, p.Destroy(context.Background(), "vm-1"))
515
516 assert.Empty(t, signalled, "SIGTERM and SIGKILL went to a pid this agent never started")
517 _, statErr := os.Stat(p.pidPath("vm-1"))
518 assert.True(t, os.IsNotExist(statErr), "nothing of ours is left, so the record must go")
519 }
520
521 func TestAPidfileWithoutABootIDIsStillOurs(t *testing.T) {
522 p := newTestProv(t, nil)
523 p.bootID = func() string { return "boot-a" }
524 var signalled []syscall.Signal
525 p.signal = func(_ int, sig syscall.Signal) error { signalled = append(signalled, sig); return nil }
526 // The format an agent that predates the boot id wrote. Refusing it would be
527 // worse than trusting it: reconcile would read the VM as lost and boot a
528 // second vfkit onto the same disk image.
529 writePidfile(t, p, "vm-1", "4321\n")
530
531 assert.True(t, p.Running("vm-1"))
532 require.NoError(t, p.Destroy(context.Background(), "vm-1"))
533 assert.Contains(t, signalled, syscall.SIGKILL)
534 }
535
536 // TestBootedVMSurvivesCtxCancellation pins the VM-lifetime contract: the vfkit
537 // process must NOT die when the context passed to Boot is cancelled. The
538 // agent's root context is cancelled on every graceful agent stop, and guests
539 // are meant to outlive the agent (that is why Boot uses Setsid and why its
540 // context parameter is deliberately unused). Killing a VM is the exclusive job
541 // of Shutdown/Destroy. cloudhv carries the same test for the same reason.
542 func TestBootedVMSurvivesCtxCancellation(t *testing.T) {
543 bin, _ := argRecorder(t)
544 p := newTestProv(t, nil)
545 p.bin = bin
546 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
547 t.Cleanup(func() { _ = p.Destroy(context.Background(), "vm-1") })
548
549 ctx, cancel := context.WithCancel(context.Background())
550 require.NoError(t, p.Boot(ctx, "vm-1", testSpec()))
551 require.True(t, p.Running("vm-1"), "the process must be alive right after Boot")
552
553 cancel()
554
555 // Poll rather than sleep once: exec.CommandContext's SIGKILL lands and is
556 // reaped within milliseconds, so a regression fails immediately and 300ms
557 // is orders of magnitude of margin.
558 deadline := time.Now().Add(300 * time.Millisecond)
559 for time.Now().Before(deadline) {
560 require.True(t, p.Running("vm-1"),
561 "the vfkit process died with the context passed to Boot — a guest's lifetime is not the agent's")
562 time.Sleep(50 * time.Millisecond)
563 }
564 }
565
566 // varStoreRecorder writes a fake vfkit that initialises the EFI variable store
567 // the way the real one does, then records its argv and sleeps. Creating the
568 // store FIRST means argv's arrival proves the store exists, so a second Boot
569 // can be sequenced against it without a second wait.
570 func varStoreRecorder(t *testing.T, varStore string) (bin, argsFile string) {
571 t.Helper()
572 dir := t.TempDir()
573 bin, argsFile = filepath.Join(dir, "fake-vfkit"), filepath.Join(dir, "argv")
574 script := fmt.Sprintf("#!/bin/sh\ntouch %s\nprintf '%%s\\n' \"$@\" > %s\nexec sleep 30\n", varStore, argsFile)
575 require.NoError(t, os.WriteFile(bin, []byte(script), 0o700))
576 return bin, argsFile
577 }
578
579 func TestASecondBootKeepsTheVariableStoreTheGuestWrote(t *testing.T) {
580 p := newTestProv(t, nil)
581 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
582 bin, argsFile := varStoreRecorder(t, p.varStore("vm-1"))
583 p.bin = bin
584 t.Cleanup(func() { _ = p.kill("vm-1") })
585
586 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
587 assert.Contains(t, waitForArgv(t, argsFile), "efi-vars.fd,create",
588 "the first boot of a VM must initialise its EFI variable store")
589
590 require.NoError(t, p.kill("vm-1"))
591 require.NoError(t, os.Remove(argsFile))
592 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
593
594 // The store is the guest's NVRAM and holds the boot entry Ubuntu writes on
595 // first boot. Re-initialising it every launch throws that away and leaves
596 // the guest booting only by the removable-media fallback path.
597 assert.NotContains(t, waitForArgv(t, argsFile), ",create",
598 "a boot that already has a variable store must not recreate it")
599 }
600
601 func TestBootRebuildsAVariableStoreItCannotStat(t *testing.T) {
602 p := newTestProv(t, nil)
603 require.NoError(t, os.MkdirAll(p.st.VMDir("vm-1"), 0o700))
604 bin, argsFile := varStoreRecorder(t, p.varStore("vm-1"))
605 p.bin = bin
606 t.Cleanup(func() { _ = p.kill("vm-1") })
607 // A store whose stat fails for a reason that is not ENOENT — here a symlink
608 // loop, on a real host an EACCES from a state dir whose ownership moved.
609 // Matching ENOENT alone read every one of those as "the store is there" and
610 // launched vfkit against NVRAM it could not open, on every retry forever.
611 require.NoError(t, os.Symlink(p.varStore("vm-1"), p.varStore("vm-1")))
612
613 require.NoError(t, p.Boot(context.Background(), "vm-1", testSpec()))
614
615 assert.Contains(t, waitForArgv(t, argsFile), ",create")
616 }
617
618 // waitForArgv returns the argv the fake vfkit recorded, once it has.
619 func waitForArgv(t *testing.T, argsFile string) string {
620 t.Helper()
621 var argv []byte
622 require.Eventually(t, func() bool {
623 var err error
624 argv, err = os.ReadFile(argsFile)
625 return err == nil && len(argv) > 0
626 }, 5*time.Second, 20*time.Millisecond, "the fake vfkit never recorded its arguments")
627 return string(argv)
628 }
629
630 func TestShutdownDialsTheVMsOwnSocket(t *testing.T) {
631 p := newTestProv(t, nil)
632 first := serveREST(t, p, "vm-1", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) })
633 second := serveREST(t, p, "vm-2", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) })
634
635 // One client serves every VM on the host, and the URL's host — the only
636 // thing an idle connection is pooled under — is the same placeholder for
637 // all of them. A pooled connection would answer the wrong VM's socket.
638 require.NoError(t, p.Shutdown(context.Background(), "vm-1"))
639 require.NoError(t, p.Shutdown(context.Background(), "vm-2"))
640
641 assert.Len(t, *first, 1)
642 assert.Len(t, *second, 1)
643 }
644
645 func TestShutdownDoesNotLeakAConnectionPerCall(t *testing.T) {
646 p := newTestProv(t, nil)
647 serveREST(t, p, "vm-1", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) })
648 require.NoError(t, p.Shutdown(context.Background(), "vm-1"))
649
650 assertNoGoroutineGrowth(t, func() {
651 for range restCallCount {
652 require.NoError(t, p.Shutdown(context.Background(), "vm-1"))
653 }
654 })
655 }
internal/arch/arch_test.go
Old New
@@ -223,6 +223,7 @@ func TestOnlyProvisionersAndRootImportOsExecInDataPlane(t *testing.T) {
223 g := directImports(t) 223 g := directImports(t)
224 allowed := map[string]bool{ 224 allowed := map[string]bool{
225 module + "/internal/agent/cloudhv": true, // provisioner: spawns cloud-hypervisor 225 module + "/internal/agent/cloudhv": true, // provisioner: spawns cloud-hypervisor
226 module + "/internal/agent/vfkit": true, // provisioner: spawns vfkit
226 module + "/internal/agent/run": true, // composition root: builds hostRunner 227 module + "/internal/agent/run": true, // composition root: builds hostRunner
227 } 228 }
228 for pkg, offenders := range execViolations(g, module, "internal/agent/", allowed) { 229 for pkg, offenders := range execViolations(g, module, "internal/agent/", allowed) {
@@ -233,8 +234,8 @@ func TestOnlyProvisionersAndRootImportOsExecInDataPlane(t *testing.T) {
233 } 234 }
234 235
235 // R7: external process execution anywhere in internal/ is confined to a 236 // R7: external process execution anywhere in internal/ is confined to a
236 // sanctioned allowlist — cloudhv (a provisioner: spawning the VMM directly is 237 // sanctioned allowlist — cloudhv and vfkit (provisioners: spawning the VMM
237 // what a provisioner is, R6's narrower story), agent/run (the agent 238 // directly is what a provisioner is, R6's narrower story), agent/run (the agent
238 // composition root, which builds the Runner it injects into every other 239 // composition root, which builds the Runner it injects into every other
239 // package), cli (interactive ssh must be the real OpenSSH client), shape 240 // package), cli (interactive ssh must be the real OpenSSH client), shape
240 // (architecture tooling that shells out to `go list`, the same introspection 241 // (architecture tooling that shells out to `go list`, the same introspection
@@ -246,6 +247,7 @@ func TestExecIsConfinedToSanctionedPackages(t *testing.T) {
246 g := directImports(t) 247 g := directImports(t)
247 allowed := map[string]bool{ 248 allowed := map[string]bool{
248 module + "/internal/agent/cloudhv": true, // provisioner: spawns the VMM directly (R6's story) 249 module + "/internal/agent/cloudhv": true, // provisioner: spawns the VMM directly (R6's story)
250 module + "/internal/agent/vfkit": true, // provisioner: the same sanction, one platform over
249 module + "/internal/agent/run": true, // agent composition root: builds the injected Runner 251 module + "/internal/agent/run": true, // agent composition root: builds the injected Runner
250 module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client 252 module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client
251 module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make 253 module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make
scripts/coverage.sh
Old New
@@ -24,7 +24,7 @@ declare -A FLOOR=(
24 [internal/agent/imagecache]=72 24 [internal/agent/imagecache]=72
25 [internal/agent/netenv]=76 25 [internal/agent/netenv]=76
26 [internal/agent/cloudhv]=40 26 [internal/agent/cloudhv]=40
27 [internal/agent/inert]=95 27 [internal/agent/vfkit]=85
28 [internal/agent/syncclient]=74 28 [internal/agent/syncclient]=74
29 [internal/server/api]=76 29 [internal/server/api]=76
30 [internal/server/boot]=17 30 [internal/server/boot]=17