a73x

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`.