docs/architecture.md
Ref: Size: 10.1 KiB History
# Eitri architecture & invariants This document records the architectural invariants that keep Eitri's control plane and data plane decoupled, and the **executable** governance that enforces them. Every invariant below is backed by a test or lint rule—if you violate one, `make ci` fails. The intent is that the architecture cannot silently drift. ## The two planes Eitri is split into two independently deployable halves that run on different hosts and share only a wire contract: | Plane | Packages | Role | |-------|----------|------| | **Control plane** (server) | `internal/server/*`, `cmd/eitri-server` | Holds *desired* state, tracks *actual* state, exposes the API + SSE UI. Never touches a VM. | | **Data plane** (agent) | `internal/agent/*`, `cmd/eitri-agent` | Owns the entire VM lifecycle: disks, networking, VMM processes. | | **Wire contract** | `internal/pb`, `internal/transport` | The only code shared across the boundary: protobuf messages + QUIC framing/TLS. | The server expresses intent as a `pb.Snapshot` of `pb.VMSpec`s (server → agent); the agent reports back a `pb.Report` of `pb.VMStatus`es (agent → server). The loop is desired-state and stays named that way, but the messages are not: every message in a snapshot is desired by construction and every message in a report is actual, so the adjective describes the envelope and the messages are spelled spec and status. The server never touches a VM and never executes a process: besides writing desired state to its store and poking the SSE hub, its only real-world side effects are control-plane services (the SSH jump gate and cert minting). All side effects on VMs and hosts live in the agent. Real SSH into a guest goes through the **SSH jump gate** (`internal/server/sshgate` + `internal/server/sshca`): the server holds a **host** CA, signs the host certificates the gate and every VM present, and tunnels TCP:22 to the VM over the existing server↔agent sync channel. It holds no user signing key—you sign your own short-lived user cert with your tenant's own CA, whose private half never leaves your machine. There is no user network—a VM is reachable at the guest address its host reports (`assigned_ip`), dialled by the agent on that host. How the guest came by that address is the host's business: a Linux host allocates it from a bridge it builds, a Mac reads what vmnet's own DHCP handed out. ## Invariants | # | Invariant | Enforced by | |---|-----------|-------------| | **R1** | The control plane and the data plane never import each other (even transitively). | `internal/arch` `TestControlAndDataPlaneAreDisjoint` (production, transitive) + `depguard` `server-no-agent` / `agent-no-server` (non-test files). | | **R2** | No `internal/server` package shells out—the server is pure control plane. Checked transitively: an internal wrapper around `os/exec` cannot smuggle a shell-out in. | `internal/arch` `TestServerNeverShellsOut` (transitive) + `depguard` `server-no-exec` (direct, fast in-editor). | | **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` `TestWirePlaneIsLeaf`. Behavior pinned by `transport` round-trip contract tests. | | **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`. | | **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). The host-wide counterpart crosses the *sync* seam the same way: a host reports the subnet its guests are on (`guest_cidr`) rather than being told which one to build, so the fleet records an observation and never infers a mechanism from it. | Convention (below) + `ireturn` allow-list keeps the seam's interface returns honest. | | **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). | | **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`. | > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see > edges changing elsewhere in the module. Always run them with `-count=1` > (`make arch` does). ## Design conventions These aren't all mechanically enforced, but they're how the code is shaped and why the invariants hold: 1. **Consumer-side interfaces only.** An interface is declared by the package that *uses* it, not the one that implements it. `reconcile` owns `Provisioner`; `cloudhv` and `vfkit` implement it. `cloudhv` in turn owns `Network`, which `netenv` implements. Keep them minimal. This is what let the second VMM land — vfkit on macOS, where the host OS assigns guest addresses instead of the agent — without touching the reconcile loop. 2. **Dependency injection via struct + function fields, no DI framework.** `reconcile.Engine` is the template: collaborators as interface fields (`Prov`), pure side effects as func fields (`Images`, `Seed`, `BootID`, `Now`). Injected time (`Now func() time.Time`) is the sanctioned way to make decision logic testable—don't call `time.Now()` directly in reconcile/store decision paths. 3. **No mutable global state.** Constructors (`store.Open`, `hub.New`, `syncsvc.New`) own all state. Immutable package vars (compiled regexps) are fine. 4. **Mock only true external dependencies.** Tests use hand-written fakes for the boundary interfaces (`Provisioner`, `cloudhv.Network`) and the `exec.Runner`. The SQLite store is used for real in tests, not mocked. Don't introduce a mocking framework—hand-written fakes keep tests honest about real behavior. 5. **The server expresses intent, never actuates.** A new server feature that wants something to happen to a VM adds it to desired state; the agent makes it so. ## The quality gate `make ci` is the merge gate. It runs on your machine—the `.githooks/pre-push` hook (installed by `make hooks`) runs it on any push that updates `main` and blocks a red one: | Step | Target | Blocks merge? | |------|--------|---------------| | `go vet` | `make vet` | yes | | Compile all packages | `make build-go` | yes | | Compile the agent for darwin/arm64 | `make build-darwin` | yes | | Architecture fitness tests (R1–R14) | `make arch` | yes | | Block-tier lint (boundaries + correctness) | `make lint` | yes | | `gofmt` drift | `make fmt-check` | yes | | Race-detector tests | `make test` | yes | | Per-package coverage ratchet | `make cover` | yes | | `go mod tidy` drift | `make tidy-check` | yes | | Generated protobuf drift | `make proto-check` | yes | | Generated API contract drift (`docs/openapi.json`, `web/src/lib/api-types.ts`) | `make api-check` | yes | | Architecture-shape diagram drift | `make shape-check` | yes | | Code unreachable from any `cmd/` entrypoint, on linux/amd64 and darwin/arm64 | `make deadcode` | yes | | The eitri.sh site renders, docs included | `make site-check` | yes | | Console unit tests (vitest) | `make web-test` | yes | | Console typecheck (svelte-check) | `make web-check` | yes | | Complexity/style lint | `make lint-extra` | **no** (informational) | `web-test` and `web-check` need `web/node_modules`; without it they print a skip rather than failing, so run `make web` once before trusting a green `ci` on console changes. Real-VM verification is not part of the per-PR gate: `make deploy` runs a boot-gate (`cmd/eitri-smoke`) against the live fleet—create a throwaway VM, prove it boots under UEFI, reap it—which is the fleet's one automated real-VM check. Because the fleet binaries are deployed with `-cover`, the same run also collects merged server+agent integration coverage (flushed on SIGUSR1 via `internal/covsnap`). ### Linting tiers `.golangci.yml` enables only the **block tier**—linters that are clean today and must stay clean (`govet`, `staticcheck`, `ineffassign`, `unused`, `bodyclose`, `rowserrcheck`, `sqlclosecheck`, `contextcheck`, `containedctx`, `depguard`, `ireturn`). The **warn tier** (`errcheck`, `revive`, `gocyclo`, `funlen`, `gocritic`, `misspell`, `unconvert`, `nakedret`) runs via `make lint-extra` with exit code 0. Promote a warn linter into the block list once its baseline is clean. ### Coverage ratchet `scripts/coverage.sh` floors each package a few points below its current coverage. `make cover` fails if any package drops below its floor; raise the floor when you raise coverage. Aspirational targets (not yet enforced): logic/domain packages → 80%, host-touching effectful packages → 50%. Generated code (`internal/pb`) and thin `cmd/*` mains are not gated here. ### The shape diagram `make shape` regenerates `docs/shape.html`—a self-contained, explorable view of the package graph (open it directly in a browser; no server needed). It is generated from `go list`, so it cannot drift from the code; `make shape-check` gates it. `docs/shape.json` is the authoritative model that produces it. New top-level packages must be classified in `internal/shape/classify.go`, enforced by `TestNoUnclassifiedPackagesInModule`.