e034a30e
feat(agent): macOS hosts run guests through vfkit
a73x 2026-08-06 09:12
Commit message
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 |