a73x

README

eitri

A control plane for running virtual machines on your own hardware.

eitri turns a pool of machines into a small VM cloud. You describe the guests you want; each host runs an agent that makes reality match that description and reports back. Guests boot as real VMs under UEFI, own their own kernel, and get a sticky IP: on Linux under cloud-hypervisor on a per-host bridge, on macOS under Apple's Virtualization.framework via vfkit.

How it works

eitri is built around a single desired-state loop, the same shape as a kubelet:

  eitri-server  ──Snapshot──▶  eitri-agent  ──▶  guests (the host's VMM)
  (control plane)              (one per host)
       ▲                            │
       └───────────Report───────────┘   (also the heartbeat)
  • The control plane (eitri-server) holds the desired fleet—which VMs should exist, on which host, with what resources—and streams it to each host over a persistent QUIC connection.
  • The agent (eitri-agent) reconciles: it gives every VM its own worker goroutine that creates, converges, or tears down that one guest, so a slow operation on one VM never stalls the others or the host's heartbeat. It reports the actual state back on the same stream; that report doubles as the heartbeat, and the control plane marks a host offline after ~30s of silence. Each agent reports its binary version and can be upgraded per host from the fleet console, which also signals when a newer eitri release is available.
  • State is desired-state, not RPC. The loop is level-triggered: a failed step is retried on the next tick, and a host that reconnects re-derives everything from persisted records plus what it observes on the box.

The agent owns everything host-local: resource admission (vCPU / memory / disk / address are admitted through one serialized gate), addressing (on Linux an embedded DHCP server hands each VM a sticky, deterministic address and reserves it at create; on macOS the OS's own NAT assigns it and the agent reads the lease), and a content-addressed image cache (each base image is downloaded and decoded to raw once, then reflink-copied per guest).

Components

BinaryRole
eitri-serverControl plane: HTTP API, the MCP endpoint at /mcp, QUIC sync stream, and the SSH-CA jump gate.
eitri-agentHost agent: enrolls a host, reconciles its VMs, drives the host's VMM.
eitriClient CLI: signs an ephemeral cert with a tenant CA and reaches a guest through the gate.

eitri-shape (regenerate the architecture graph) rounds out the binaries.

Access model

eitri is multi-tenant. A fleet of hosts is partitioned into tenants, each its own isolated namespace with its own SSH user CA—eitri holds no tenant user signing key. You reach a guest by name:

eitri ssh <vm-name>

eitri ssh self-signs a short-lived certificate with your tenant's user CA and jumps through the server's gate, which authorizes the connection against the tenant derived from the signing CA. Guest host certificates are namespaced the same way, so names never collide across tenants.

Getting started

Build everything:

make build      # binaries into ./bin

Enrolling a real host. The server mints a single-paste join blob; on the host, eitri-agent join <blob> enrolls it (posting to /api/v1/enroll), pins the server certificate from the blob, and persists its identity. From then on the agent runs the reconcile + sync loop against the fleet. Run it under systemd with scripts/eitri-agent.serviceRestart=on-failure revives a crashed agent, and its KillMode=process keeps running VMs alive across agent stops.

Guests boot from cloud images (the default is Ubuntu resolute) under UEFI, so the guest owns its kernel and any disk-only image boots unmodified. On Linux that is the CLOUDHV.fd firmware shipped to each host; on macOS it is the framework's own EFI bootloader, and each guest keeps its NVRAM beside its disk.

Documentation

docs/ is indexed by what you're trying to do—understand it (architecture, ethos), run a fleet (cert rotation, revocation), use a fleet (connecting, mcp), or see why it's built this way (decisions). What ships next is in ROADMAP.md.

Repository layout

cmd/            entrypoints (eitri-server, eitri-agent, eitri, tooling)
internal/
  agent/        reconcile loop, VMM drivers (cloudhv, vfkit), DHCP, image cache, netenv
  server/       API, QUIC sync service, SSH gate/CA, store, registry, hub
  transport/    QUIC transport shared by both sides
  pb/           generated protobuf (proto/eitri/v1)
web/            SvelteKit fleet console, embedded into eitri-server
proto/          the wire contract
docs/shape.*    the generated, explorable architecture graph

Development

make ci is the gate—everything a change must pass before it lands:

make ci

It runs go vet, the build for linux and darwin, the architecture fitness functions, golangci-lint, gofmt, the test suite under -race, per-package coverage floors, the drift checks (go mod tidy, protobuf, API contract, shape graph), the deadcode sweep, a site render, and the console's tests and typecheck. The .githooks/pre-push hook—installed by make hooks—runs it and blocks a red push to main.

Common loops:

make test       # go test -race
make deploy     # roll HEAD to the fleet (local server + remote agents) — runs a real-VM boot-gate
make shape      # regenerate docs/shape.{json,html} after a package change

Open Patches

No open patches.

Open Issues

ID Title Author
011250a2 internal/server/api is the weakest package on both mutation axes a73x
017d47bc site: 53 surviving mutants (largest pool in the tree) a73x
08bfa70b A host's declared cap never reaches the wire a73x
08d8325c Untested code: 419 mutants no test reaches (boot, run, hostinfo, smoke) a73x
11b9c17a Wire: reserve DesiredVM field 9 (persistent) now the fleet is past v0.0.5 a73x
3465dd3d [followup] vcpus/mem_mb=0 burns full create budget (permanence gap) a73x
359ee0d8 [followup] captureLogs exists byte-identical in three packages and is t.Parallel-hostile a73x
40ab963a serialpump: 17 surviving mutants, 58.5% efficacy (worst in tree) a73x
431cc417 There is no CI: the pre-push hook is the only gate a73x
4636c0b9 image_url is unvalidated and the image download is uncapped a73x
481e55be make ci fails on a fresh worktree: the web coverage floor assumes a built SPA a73x
4cb268b3 [followup] internal/agent/syncclient coverage floor 82 is flaky (timing-sensitive test) a73x
4cdf3438 Mac TCC: the agent's per-binary permissions are unproven since the rewrite a73x
4d3c3a86 Retire testPAT/testReg from the api test packages a73x
4ea6afac Capabilities(): the Provisioner seam has no verb for what a host can do a73x
571002f4 eitri is the only thing that can dial an eitri guest: SSHArgv never emits ssh_config a73x
57f6d164 No GET /api/v1/vms/{id}: a single VM can only be read by polling the list a73x
5a39478b Teardown observability: a quarantined VM reports power_state=running with all actual-state fields blank a73x
679f347a Flaky: TestUDPSessionIsPromotedOnlyByAGuestReply races the promotion a73x
7250604a Host cert lifecycle: 10-year certs, no revocation, reusable VM names defeat 'recreate the VM' a73x
73c53e44 Coverage gate is fail-open: an unlisted package is silently ungated a73x
8499750c [followup] exposeproxy TestUDPSessionIsPromotedOnlyByAGuestReply flakes under whole-suite load a73x
8b89d197 Volumes have no CLI, MCP, or console surface: v0.0.8 shipped an HTTP API and nothing else a73x
991e03e0 A jinja or gzipped cloud_init is refused at the seed build, not as a 400 at the API a73x
9939dbde [followup] ParseACK lacks a fragment guard (filter is the only defense) a73x
99613bd8 Enrollment rate-limit bucket scope unresolved: per-source or plane-wide? a73x
999147f6 MCP has no host_list: the fleet is only visible through error messages a73x
9a7edf81 No idle timeout on established gate tunnels a73x
9e0b280e cloudhv tests SIGKILL the process group when kill guard is mutated a73x
a0a18e7f syncclient: leaked Run goroutine races the computeCapacity test hook under -shuffle a73x
a2aa88b8 Multi-identity: one operator, several identities across planes a73x
ab893035 The join command never emits --bridge-cidr or --host-network a73x
b3c1d136 [followup] imagecache eviction uses apparent size, not allocated blocks a73x
c4f319d0 API vocabulary: deprecations owed, scope not recorded a73x
cf4a75da Smoke VMs leak: smoke-c546aac6 survived 11 days on onyx a73x
d78939ca Mutation-testing baseline 2026-08-22: 281 surviving mutants, 419 unreached a73x
db8dab2c RecordVMStatus is unscoped by host: any agent can write any VM's status a73x
e698610c VMs cannot move between hosts a73x
e8a9c92a Guest/gate principal collision: safe here, unguarded for self-hosters on a 2-label gate domain a73x
f1fd3117 syncclient: 10 surviving mutants on the agent's control-plane link a73x
f283feff Revisit mutate-report: threshold, or drop it a73x
fb095e7b VM backends (vfkit, cloudhv) lack build tags: macOS tests compile and fail on Linux (and vice versa) a73x
fe02e480 Smoke places its VM on hostList[0] but reads serial from AGENT_HOSTS a73x
fe206dc0 Serverless: design-first, no code yet a73x

Recent Commits

Commit Summary Author Date
e4d75234 smoke: a host nobody could converge cannot fail a release a73x 2026-09-05
331baaef backup: a job that moved registries needs the credential to match a73x 2026-09-05
a34381fd backup: the image takes its fixes from the distro too a73x 2026-09-05
6b45c813 site: the image takes its fixes from the distro, not the base tag a73x 2026-09-05
0bebb556 retro: what the volumes release cost a73x 2026-09-05
ea6a7dcc ship: the tag probe asks the package that now owns ordering a73x 2026-09-05
3113fc99 docs: v0.0.8 release notes, and the volumes page joins the index a73x 2026-09-05
39c407f2 volumes: durable block storage a VM attaches at create a73x 2026-09-05
7bf37937 release: one Feature shape for every version floor a73x 2026-09-05
1ca7b3d5 vms: a host that says nothing cannot vouch for its guests a73x 2026-09-05