7dd41af7
wire: what the rename argued, the tree now enforces
a73x 2026-08-23 05:47
Commit message
.golangci.yml
| Old | New | ||
|---|---|---|---|
| @@ -85,6 +85,12 @@ linters: | |||
| 85 | deny: | 85 | deny: |
| 86 | - pkg: github.com/a73x/eitri/internal/server | 86 | - pkg: github.com/a73x/eitri/internal/server |
| 87 | desc: data plane (agent) must not import control plane (server) | 87 | desc: data plane (agent) must not import control plane (server) |
| 88 | no-protojson: # R15: protobuf names are not a contract | ||
| 89 | files: | ||
| 90 | - "$all" | ||
| 91 | deny: | ||
| 92 | - pkg: google.golang.org/protobuf/encoding/protojson | ||
| 93 | desc: protobuf serialises field numbers, not names; renaming a wire message must stay free — serialise through internal/server/api/types | ||
| 88 | domain-no-transport: # R4: pure domain stays serialization-agnostic | 94 | domain-no-transport: # R4: pure domain stays serialization-agnostic |
| 89 | files: | 95 | files: |
| 90 | - "**/internal/agent/state/**" | 96 | - "**/internal/agent/state/**" |
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -15,6 +15,9 @@ GOVULN := $(shell go env GOPATH)/bin/govulncheck | |||
| 15 | # .golangci.yml's enable list once a linter's baseline is clean. | 15 | # .golangci.yml's enable list once a linter's baseline is clean. |
| 16 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret | 16 | LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret |
| 17 | # Pinned mutation tester (github.com/go-gremlins/gremlins). Bump deliberately. | 17 | # Pinned mutation tester (github.com/go-gremlins/gremlins). Bump deliberately. |
| 18 | # Shared by `proto` and `proto-check` so the two can never generate differently. | ||
| 19 | PROTOC_OPTS := --go_opt=module=github.com/a73x/eitri proto/eitri/v1/sync.proto | ||
| 20 | |||
| 18 | GREMLINS_VERSION := v0.6.0 | 21 | GREMLINS_VERSION := v0.6.0 |
| 19 | GREMLINS := $(shell go env GOPATH)/bin/gremlins | 22 | GREMLINS := $(shell go env GOPATH)/bin/gremlins |
| 20 | GREMLINS_WORKERS ?= 2 | 23 | GREMLINS_WORKERS ?= 2 |
| @@ -61,8 +64,7 @@ vet: | |||
| 61 | go vet ./... | 64 | go vet ./... |
| 62 | 65 | ||
| 63 | proto: | 66 | proto: |
| 64 | protoc --go_out=. --go_opt=module=github.com/a73x/eitri \ | 67 | protoc --go_out=. $(PROTOC_OPTS) |
| 65 | proto/eitri/v1/sync.proto | ||
| 66 | 68 | ||
| 67 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote | 69 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote |
| 68 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from | 70 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from |
| @@ -218,8 +220,19 @@ mutate-report: gremlins-tool | |||
| 218 | --timeout-coefficient $(GREMLINS_TIMEOUT) \ | 220 | --timeout-coefficient $(GREMLINS_TIMEOUT) \ |
| 219 | -D $(MUTATE_BASE) . 2>&1 | tail -6 || true | 221 | -D $(MUTATE_BASE) . 2>&1 | tail -6 || true |
| 220 | 222 | ||
| 223 | # internal/server/web's floor is measured against the REAL SPA: with only the | ||
| 224 | # committed placeholder staged, its embed-serving paths never run and the floor | ||
| 225 | # fails at 89% on a tree with nothing wrong in it (which is every fresh clone | ||
| 226 | # and every new worktree). Depend on the staged index.html rather than on `web`, | ||
| 227 | # so a dist that is already there is not rebuilt — `web` runs `npm ci`, which is | ||
| 228 | # minutes, and `cover` is not the gate that owns the SPA build (web-test and | ||
| 229 | # web-check are). A STALE dist is likewise not rebuilt, deliberately: this | ||
| 230 | # prerequisite exists to make the floor measurable, not to keep the SPA current. | ||
| 231 | $(WEB_DIST)/index.html: | ||
| 232 | $(MAKE) web | ||
| 233 | |||
| 221 | # Per-package coverage ratchet (see scripts/coverage.sh). | 234 | # Per-package coverage ratchet (see scripts/coverage.sh). |
| 222 | cover: | 235 | cover: $(WEB_DIST)/index.html |
| 223 | ./scripts/coverage.sh | 236 | ./scripts/coverage.sh |
| 224 | 237 | ||
| 225 | # Dependency hygiene: `go mod tidy` must produce no diff. | 238 | # Dependency hygiene: `go mod tidy` must produce no diff. |
| @@ -250,18 +263,27 @@ tidy-check: | |||
| 250 | go mod tidy | 263 | go mod tidy |
| 251 | git diff --exit-code go.mod go.sum | 264 | git diff --exit-code go.mod go.sum |
| 252 | 265 | ||
| 253 | # Generated protobuf must match proto/eitri/v1/sync.proto. The `-I` ignores the | 266 | # The committed internal/pb must be what today's proto/eitri/v1/sync.proto |
| 254 | # protoc/protoc-gen-go version-stamp comment lines so the gate tracks real code | 267 | # produces. Generate into a throwaway tree and diff THAT against the committed |
| 255 | # drift rather than the exact toolchain patch version. Skips (does not fail) | 268 | # file, which is the actual invariant: regenerating in place and running |
| 256 | # when protoc is unavailable so `make ci` still runs on protoc-less machines; | 269 | # `git diff --exit-code` instead compares the worktree against HEAD, so an |
| 257 | # CI installs protoc, so the gate is enforced there. | 270 | # in-progress .proto edit fails the gate even when its generated output is |
| 271 | # perfectly consistent — the gate refuses a correct tree for being unstaged. | ||
| 272 | # The `-I` drops the protoc/protoc-gen-go version-stamp comments so the gate | ||
| 273 | # tracks real code drift rather than the exact toolchain patch version. Skips | ||
| 274 | # (does not fail) when protoc is unavailable so `make ci` still runs on | ||
| 275 | # protoc-less machines; CI installs protoc, so the gate is enforced there. | ||
| 258 | proto-check: | 276 | proto-check: |
| 259 | @if ! command -v protoc >/dev/null 2>&1; then \ | 277 | @if ! command -v protoc >/dev/null 2>&1; then \ |
| 260 | echo "proto-check: protoc not installed — SKIPPING (enforced in CI)"; \ | 278 | echo "proto-check: protoc not installed — SKIPPING (enforced in CI)"; \ |
| 261 | else \ | 279 | exit 0; \ |
| 262 | $(MAKE) proto && git diff --exit-code -I '^//[[:space:]]+protoc' internal/pb || \ | 280 | fi; \ |
| 263 | { echo "proto-check: internal/pb is stale — run 'make proto'"; exit 1; }; \ | 281 | tmp=$$(mktemp -d) || exit 1; \ |
| 264 | fi | 282 | trap 'rm -rf "$$tmp"' EXIT; \ |
| 283 | protoc --go_out="$$tmp" $(PROTOC_OPTS) || \ | ||
| 284 | { echo "proto-check: protoc failed on proto/eitri/v1/sync.proto"; exit 1; }; \ | ||
| 285 | diff -ru -I '^//[[:space:]]*protoc' "$$tmp/internal/pb" internal/pb || \ | ||
| 286 | { echo "proto-check: internal/pb is stale — run 'make proto'"; exit 1; } | ||
| 265 | 287 | ||
| 266 | # Regenerate the explorable architecture-shape diagram (docs/shape.{json,html}). | 288 | # Regenerate the explorable architecture-shape diagram (docs/shape.{json,html}). |
| 267 | shape: | 289 | shape: |
README.md
| Old | New | ||
|---|---|---|---|
| @@ -14,10 +14,10 @@ on a per-host bridge, on macOS under Apple's Virtualization.framework via | |||
| 14 | 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: |
| 15 | 15 | ||
| 16 | ``` | 16 | ``` |
| 17 | eitri-server ──DesiredStateSnapshot──▶ eitri-agent ──▶ guests (the host's VMM) | 17 | eitri-server ──Snapshot──▶ eitri-agent ──▶ guests (the host's VMM) |
| 18 | (control plane) (one per host) | 18 | (control plane) (one per host) |
| 19 | ▲ │ | 19 | ▲ │ |
| 20 | └──────────ActualStateReport─────────────┘ (also the heartbeat) | 20 | └───────────Report───────────┘ (also the heartbeat) |
| 21 | ``` | 21 | ``` |
| 22 | 22 | ||
| 23 | - **The control plane (`eitri-server`)** holds the desired fleet—which VMs | 23 | - **The control plane (`eitri-server`)** holds the desired fleet—which VMs |
RETRO.md
| Old | New | ||
|---|---|---|---|
| @@ -17,3 +17,7 @@ One line per push to `main`: what slowed the work down. Enforced by | |||
| 17 | an empty web/dist, so the web package misses its coverage floor until | 17 | an empty web/dist, so the web package misses its coverage floor until |
| 18 | `make web` runs; and proto-check diffs generated code against the COMMIT, | 18 | `make web` runs; and proto-check diffs generated code against the COMMIT, |
| 19 | so it always fails on an uncommitted .proto change. Neither gate says so. | 19 | so it always fails on an uncommitted .proto change. Neither gate says so. |
| 20 | - Wire-rename followups: proving "a v0.0.7 agent still talks to this server" | ||
| 21 | meant rebuilding the old descriptor set from the tag by hand. The tree keeps | ||
| 22 | every version of the .proto and no artifact of what a release actually put on | ||
| 23 | the wire, so the compatibility claim had nowhere to be checked against. | ||
docs/architecture.md
| Old | New | ||
|---|---|---|---|
| @@ -16,12 +16,16 @@ hosts and share only a wire contract: | |||
| 16 | | **Data plane** (agent) | `internal/agent/*`, `cmd/eitri-agent` | Owns the entire VM lifecycle: disks, networking, VMM 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.Snapshot` (server → agent); the | 19 | The server expresses intent as a `pb.Snapshot` of `pb.VMSpec`s (server → |
| 20 | agent reports back a `pb.Report` (agent → server). The server never | 20 | agent); the agent reports back a `pb.Report` of `pb.VMStatus`es (agent → |
| 21 | touches a VM and never executes a process: besides writing desired state to its | 21 | server). The loop is desired-state and stays named that way, but the messages |
| 22 | store and poking the SSE hub, its only real-world side effects are control-plane | 22 | are not: every message in a snapshot is desired by construction and every |
| 23 | services (the SSH jump gate and cert minting). All side effects on VMs and hosts | 23 | message in a report is actual, so the adjective describes the envelope and the |
| 24 | live in the agent. | 24 | messages are spelled spec and status. The server never touches a VM and never |
| 25 | executes a process: besides writing desired state to its store and poking the | ||
| 26 | SSE hub, its only real-world side effects are control-plane services (the SSH | ||
| 27 | jump gate and cert minting). All side effects on VMs and hosts live in the | ||
| 28 | agent. | ||
| 25 | 29 | ||
| 26 | Real SSH into a guest goes through the **SSH jump gate** | 30 | Real SSH into a guest goes through the **SSH jump gate** |
| 27 | (`internal/server/sshgate` + `internal/server/sshca`): the server holds a | 31 | (`internal/server/sshgate` + `internal/server/sshca`): the server holds a |
docs/assumptions.md
| Old | New | ||
|---|---|---|---|
| @@ -673,3 +673,17 @@ certificate for `<tenant>.<name>` outlives the VM it was issued to, and VM names | |||
| 673 | are reusable, so deleting a VM does not retire its identity. Bounding that needs | 673 | are reusable, so deleting a VM does not retire its identity. Bounding that needs |
| 674 | a delivery channel for a replacement certificate, which does not exist yet. | 674 | a delivery channel for a replacement certificate, which does not exist yet. |
| 675 | 675 | ||
| 676 | |||
| 677 | ### A protobuf message's names are ours to change | ||
| 678 | |||
| 679 | The binary encoding carries field numbers, not names, and nothing in eitri | ||
| 680 | renders a wire message by name: `protojson` is imported nowhere, and the only | ||
| 681 | JSON that leaves the process is `internal/server/api/types`, which is | ||
| 682 | hand-written and deprecates on its own schedule. Underpins renaming the wire | ||
| 683 | vocabulary (`VMDesired` → `VMSpec`, `ActualVM` → `VMStatus`) with older agents | ||
| 684 | still connected — they write the same numbers and read the same numbers. | ||
| 685 | **Proven** 2026-08-23, and enforced rather than argued: R15 in `internal/arch` | ||
| 686 | sweeps the whole import graph for `protojson`, and `depguard` denies it in the | ||
| 687 | editor. Falsified the moment that import lands anywhere, because the names it | ||
| 688 | writes to a log or a row become a contract retroactively — and the rename after | ||
| 689 | that one breaks it silently. | ||
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -10,8 +10,10 @@ For *how* we decide, see [ethos.md](ethos.md); for what these rest on, see | |||
| 10 | The server streams desired state; agents converge toward it and report actual | 10 | The server streams desired state; agents converge toward it and report actual |
| 11 | state back. Instead of imperative RPC per operation. Level-triggered convergence | 11 | state back. Instead of imperative RPC per operation. Level-triggered convergence |
| 12 | survives disconnects, crashes, and missed messages—a host re-derives | 12 | survives disconnects, crashes, and missed messages—a host re-derives |
| 13 | everything from persisted records plus what it observes. Details in | 13 | everything from persisted records plus what it observes. The loop keeps the |
| 14 | [architecture.md](architecture.md). | 14 | adjective; the messages do not—every message in a snapshot is desired and every |
| 15 | message in a report is actual, so they are `Snapshot`/`VMSpec` down and | ||
| 16 | `Report`/`VMStatus` back. Details in [architecture.md](architecture.md). | ||
| 15 | 17 | ||
| 16 | ### Guests boot their own kernel (UEFI firmware) | 18 | ### Guests boot their own kernel (UEFI firmware) |
| 17 | 19 | ||
internal/agent/reconcile/reconcile.go
| Old | New | ||
|---|---|---|---|
| @@ -219,10 +219,10 @@ type Engine struct { | |||
| 219 | // explicit because the two carry different grace periods (VanishGrace vs the | 219 | // explicit because the two carry different grace periods (VanishGrace vs the |
| 220 | // shorter TombstoneGrace). | 220 | // shorter TombstoneGrace). |
| 221 | // | 221 | // |
| 222 | // LIFETIME: desired points into the caller's Snapshot, and a worker | 222 | // LIFETIME: desired points into the caller's Snapshot, and a worker holds that |
| 223 | // holds that pointer well past the Step that delivered it — for as long as its | 223 | // pointer well past the Step that delivered it — for as long as its pass runs, |
| 224 | // pass runs, up to VMTimeout. A snapshot handed to Step must therefore be | 224 | // up to VMTimeout. A snapshot handed to Step must therefore be treated as |
| 225 | // treated as immutable while any pass may still be running. | 225 | // immutable while any pass may still be running. |
| 226 | type assignment struct { | 226 | type assignment struct { |
| 227 | desired *pb.VMSpec | 227 | desired *pb.VMSpec |
| 228 | tombstoned bool | 228 | tombstoned bool |
| @@ -258,9 +258,9 @@ func tombstonedSet(snap *pb.Snapshot) map[string]bool { | |||
| 258 | } | 258 | } |
| 259 | 259 | ||
| 260 | // Step routes one desired-state snapshot to the per-VM workers and returns the | 260 | // Step routes one desired-state snapshot to the per-VM workers and returns the |
| 261 | // host's Report. It never waits for a worker: the report carries each | 261 | // host's Report. It never waits for a worker: the report carries each VM's |
| 262 | // VM's LAST-PUBLISHED state, so a VM busy in a multi-second operation cannot | 262 | // LAST-PUBLISHED state, so a VM busy in a multi-second operation cannot delay |
| 263 | // delay the heartbeat. A VM that has not published yet simply has no row. | 263 | // the heartbeat. A VM that has not published yet simply has no row. |
| 264 | // | 264 | // |
| 265 | // The algorithm is level-triggered: every call re-examines full state and drives | 265 | // The algorithm is level-triggered: every call re-examines full state and drives |
| 266 | // toward desired. Idempotent under repeated identical snapshots. | 266 | // toward desired. Idempotent under repeated identical snapshots. |
| @@ -353,15 +353,15 @@ func (e *Engine) fenceReport(currentEpoch uint64) *pb.Report { | |||
| 353 | res.merge(rep) | 353 | res.merge(rep) |
| 354 | continue | 354 | continue |
| 355 | } | 355 | } |
| 356 | power := "stopped" | 356 | powerState := "stopped" |
| 357 | if e.Prov.Running(rec.Spec.VMID) { | 357 | if e.Prov.Running(rec.Spec.VMID) { |
| 358 | power = "running" | 358 | powerState = "running" |
| 359 | } | 359 | } |
| 360 | phase := "ready" | 360 | phase := "ready" |
| 361 | if rec.LastError != "" { | 361 | if rec.LastError != "" { |
| 362 | phase = "failed" | 362 | phase = "failed" |
| 363 | } | 363 | } |
| 364 | res.report(rec.Spec.VMID, recAddrs(rec), power, phase, rec.LastError) | 364 | res.report(rec.Spec.VMID, recAddrs(rec), powerState, phase, rec.LastError) |
| 365 | res.merge(rep) | 365 | res.merge(rep) |
| 366 | } | 366 | } |
| 367 | return rep | 367 | return rep |
| @@ -871,11 +871,11 @@ func (e *Engine) create(ctx context.Context, d *pb.VMSpec, rec state.Record, ok | |||
| 871 | rec.LastError = "" | 871 | rec.LastError = "" |
| 872 | _ = e.St.SaveVM(rec) | 872 | _ = e.St.SaveVM(rec) |
| 873 | 873 | ||
| 874 | power := "running" | 874 | powerState := "running" |
| 875 | if d.PowerState != "running" { | 875 | if d.PowerState != "running" { |
| 876 | power = "stopped" | 876 | powerState = "stopped" |
| 877 | } | 877 | } |
| 878 | res.report(d.VmId, recAddrs(rec), power, "ready", "") | 878 | res.report(d.VmId, recAddrs(rec), powerState, "ready", "") |
| 879 | } | 879 | } |
| 880 | 880 | ||
| 881 | // permanent reports whether err (anywhere in its chain) carries the | 881 | // permanent reports whether err (anywhere in its chain) carries the |
| @@ -1011,13 +1011,13 @@ func (e *Engine) converge(ctx context.Context, d *pb.VMSpec, rec state.Record, r | |||
| 1011 | res.report(d.VmId, recAddrs(rec), "stopped", "ready", "") | 1011 | res.report(d.VmId, recAddrs(rec), "stopped", "ready", "") |
| 1012 | } else { | 1012 | } else { |
| 1013 | // Already at desired state. | 1013 | // Already at desired state. |
| 1014 | power := "stopped" | 1014 | powerState := "stopped" |
| 1015 | if running { | 1015 | if running { |
| 1016 | power = "running" | 1016 | powerState = "running" |
| 1017 | } | 1017 | } |
| 1018 | // Preserve last error in the report field but phase stays ready | 1018 | // Preserve last error in the report field but phase stays ready |
| 1019 | // (the VM is converged; the error is informational history). | 1019 | // (the VM is converged; the error is informational history). |
| 1020 | res.report(d.VmId, recAddrs(rec), power, "ready", rec.LastError) | 1020 | res.report(d.VmId, recAddrs(rec), powerState, "ready", rec.LastError) |
| 1021 | } | 1021 | } |
| 1022 | } | 1022 | } |
| 1023 | 1023 | ||
| @@ -1065,8 +1065,8 @@ func recAddrs(rec state.Record) addrs { return addrs{ip: rec.IP, networkIP: rec. | |||
| 1065 | 1065 | ||
| 1066 | // report records this VM's actual row. A VM contributes at most one row, so a | 1066 | // report records this VM's actual row. A VM contributes at most one row, so a |
| 1067 | // later call in the same reconcile replaces an earlier one. | 1067 | // later call in the same reconcile replaces an earlier one. |
| 1068 | func (r *vmResult) report(vmID string, at addrs, power, phase, lastError string) { | 1068 | func (r *vmResult) report(vmID string, at addrs, powerState, phase, lastError string) { |
| 1069 | r.vm = newVMStatus(vmID, at, power, phase, lastError) | 1069 | r.vm = newVMStatus(vmID, at, powerState, phase, lastError) |
| 1070 | } | 1070 | } |
| 1071 | 1071 | ||
| 1072 | // clone returns a deep copy, so the caller's report owns its rows outright. | 1072 | // clone returns a deep copy, so the caller's report owns its rows outright. |
| @@ -1123,12 +1123,12 @@ func byteScale(n int64) (float64, string) { | |||
| 1123 | // newVMStatus builds one VMStatus row from what a reconcile pass observed. | 1123 | // newVMStatus builds one VMStatus row from what a reconcile pass observed. |
| 1124 | // The row's remaining field, ssh_host_pubkey, is stamped by merge — see | 1124 | // The row's remaining field, ssh_host_pubkey, is stamped by merge — see |
| 1125 | // vmResult.hostPubKey. Unset values are the proto zero-value "". | 1125 | // vmResult.hostPubKey. Unset values are the proto zero-value "". |
| 1126 | func newVMStatus(vmID string, at addrs, power, phase, lastError string) *pb.VMStatus { | 1126 | func newVMStatus(vmID string, at addrs, powerState, phase, lastError string) *pb.VMStatus { |
| 1127 | return &pb.VMStatus{ | 1127 | return &pb.VMStatus{ |
| 1128 | VmId: vmID, | 1128 | VmId: vmID, |
| 1129 | Ip: at.ip, | 1129 | Ip: at.ip, |
| 1130 | NetworkIp: at.networkIP, | 1130 | NetworkIp: at.networkIP, |
| 1131 | PowerState: power, | 1131 | PowerState: powerState, |
| 1132 | Phase: phase, | 1132 | Phase: phase, |
| 1133 | LastError: lastError, | 1133 | LastError: lastError, |
| 1134 | } | 1134 | } |
internal/arch/arch_test.go
| Old | New | ||
|---|---|---|---|
| @@ -138,6 +138,34 @@ func TestWirePlaneIsLeaf(t *testing.T) { | |||
| 138 | } | 138 | } |
| 139 | } | 139 | } |
| 140 | 140 | ||
| 141 | // R15: nothing in eitri serialises a protobuf message by NAME. Protobuf's | ||
| 142 | // binary encoding carries field numbers only, which is what makes the message | ||
| 143 | // and field names in proto/eitri/v1/sync.proto free to be renamed for clarity | ||
| 144 | // without breaking an agent that predates the rename (the field-number lock in | ||
| 145 | // internal/transport guards the numbers themselves). protojson breaks that: it | ||
| 146 | // encodes names, so the moment a JSON dump, debug endpoint or MCP result is | ||
| 147 | // rendered through it, those names become a contract — and the NEXT rename | ||
| 148 | // breaks whatever already persisted them, silently and after the fact. | ||
| 149 | // | ||
| 150 | // The JSON that eitri does speak is internal/server/api/types, which is a | ||
| 151 | // hand-written contract with its own golden files and deprecation discipline. | ||
| 152 | // Route new JSON through there, not through the wire messages. | ||
| 153 | func TestNoProtobufJSONSerialization(t *testing.T) { | ||
| 154 | g := directImports(t) | ||
| 155 | const protojson = "google.golang.org/protobuf/encoding/protojson" | ||
| 156 | if len(g) == 0 { | ||
| 157 | t.Fatal("import graph is empty — go list returned nothing to sweep") | ||
| 158 | } | ||
| 159 | for pkg, deps := range g { | ||
| 160 | for _, d := range deps { | ||
| 161 | if d == protojson { | ||
| 162 | t.Errorf("package %s imports %s — protobuf names are not a contract; "+ | ||
| 163 | "serialise through internal/server/api/types instead", short(pkg), d) | ||
| 164 | } | ||
| 165 | } | ||
| 166 | } | ||
| 167 | } | ||
| 168 | |||
| 141 | // R10: modernc.org/sqlite is imported by internal/server/store and nowhere | 169 | // R10: modernc.org/sqlite is imported by internal/server/store and nowhere |
| 142 | // else. The store owns schema application and the single-connection write | 170 | // else. The store owns schema application and the single-connection write |
| 143 | // serialization; a second driver import would bypass both silently. The | 171 | // serialization; a second driver import would bypass both silently. The |
internal/pb/sync.pb.go
| Old | New | ||
|---|---|---|---|
| @@ -614,12 +614,14 @@ func (x *HostMetrics) GetDiskFreeGb() int64 { | |||
| 614 | // was told. One VM, two halves — VMSpec travels down in a Snapshot, VMStatus | 614 | // was told. One VM, two halves — VMSpec travels down in a Snapshot, VMStatus |
| 615 | // travels back in a Report, and the control plane holds both on one row. | 615 | // travels back in a Report, and the control plane holds both on one row. |
| 616 | type VMStatus struct { | 616 | type VMStatus struct { |
| 617 | state protoimpl.MessageState `protogen:"open.v1"` | 617 | state protoimpl.MessageState `protogen:"open.v1"` |
| 618 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` | 618 | VmId string `protobuf:"bytes,1,opt,name=vm_id,json=vmId,proto3" json:"vm_id,omitempty"` |
| 619 | PowerState string `protobuf:"bytes,2,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" as observed; VMSpec.power_state is what was asked for | 619 | // As observed on the host, never as asked for: VMSpec.power_state carries |
| 620 | Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined" | 620 | // the request, and the two disagreeing is what converge exists to fix. |
| 621 | Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // the guest's address on its host's NAT underlay — every guest has one, from boot | 621 | PowerState string `protobuf:"bytes,2,opt,name=power_state,json=powerState,proto3" json:"power_state,omitempty"` // "running"|"stopped" |
| 622 | LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` | 622 | Phase string `protobuf:"bytes,3,opt,name=phase,proto3" json:"phase,omitempty"` // "creating"|"ready"|"failed"|"quarantined" |
| 623 | Ip string `protobuf:"bytes,4,opt,name=ip,proto3" json:"ip,omitempty"` // the guest's address on its host's NAT underlay — every guest has one, from boot | ||
| 624 | LastError string `protobuf:"bytes,5,opt,name=last_error,json=lastError,proto3" json:"last_error,omitempty"` | ||
| 623 | // The guest's ed25519 HOST public key. It is generated on the host, and the | 625 | // The guest's ed25519 HOST public key. It is generated on the host, and the |
| 624 | // private half never leaves it — this is the only half that travels. Sent on | 626 | // private half never leaves it — this is the only half that travels. Sent on |
| 625 | // every report for as long as the VM exists (level-triggered), so a lost | 627 | // every report for as long as the VM exists (level-triggered), so a lost |
internal/server/store/store.go
| Old | New | ||
|---|---|---|---|
| @@ -477,7 +477,7 @@ func (s *Store) Close() error { return s.db.Close() } | |||
| 477 | func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } | 477 | func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } |
| 478 | 478 | ||
| 479 | // Epoch reads the current epoch value directly. Production callers get the | 479 | // Epoch reads the current epoch value directly. Production callers get the |
| 480 | // epoch via SnapshotForHost (paired with a matching desired-VM read in the | 480 | // epoch via SpecForHost (paired with a matching desired-VM read in the |
| 481 | // same tx); this exists as a test/observability hook for asserting exactly | 481 | // same tx); this exists as a test/observability hook for asserting exactly |
| 482 | // which mutations bump the epoch. | 482 | // which mutations bump the epoch. |
| 483 | func (s *Store) Epoch() (uint64, error) { | 483 | func (s *Store) Epoch() (uint64, error) { |
| @@ -1545,7 +1545,7 @@ func scanVM(rows *sql.Rows) (VM, error) { | |||
| 1545 | // queryVMs runs a vmColumns-projected SELECT against vms, with where appended | 1545 | // queryVMs runs a vmColumns-projected SELECT against vms, with where appended |
| 1546 | // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args | 1546 | // verbatim after `FROM vms` (e.g. " WHERE id=?", or "" for none) and args |
| 1547 | // bound in order, scanning every matching row. listVMs, VMByTenantName, GetVM, | 1547 | // bound in order, scanning every matching row. listVMs, VMByTenantName, GetVM, |
| 1548 | // and SnapshotForHost all share this — they differ only in WHERE clause, | 1548 | // and SpecForHost all share this — they differ only in WHERE clause, |
| 1549 | // row-count expectations, and whether q is *sql.DB or an in-flight *sql.Tx. | 1549 | // row-count expectations, and whether q is *sql.DB or an in-flight *sql.Tx. |
| 1550 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { | 1550 | func queryVMs(q querier, where string, args ...any) ([]VM, error) { |
| 1551 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) | 1551 | rows, err := q.Query(`SELECT `+vmColumns+` FROM vms`+where, args...) |
| @@ -1624,7 +1624,14 @@ func (s *Store) Snapshot() ([]Host, map[string]Alloc, []VM, error) { | |||
| 1624 | return hosts, alloc, vms, tx.Commit() | 1624 | return hosts, alloc, vms, tx.Commit() |
| 1625 | } | 1625 | } |
| 1626 | 1626 | ||
| 1627 | func (s *Store) SnapshotForHost(hostID string) (uint64, []VM, error) { | 1627 | // SpecForHost reads the desired VMs for one host plus the epoch they were read |
| 1628 | // at, in a single transaction, so the pair cannot straddle a mutation. syncsvc | ||
| 1629 | // renders the result into the pb.Snapshot that host is sent. | ||
| 1630 | // | ||
| 1631 | // Not a scoped Snapshot: that one reads the whole fleet (hosts, allocation, | ||
| 1632 | // VMs) for the SSE stream. This is one host's spec, which is why it is named | ||
| 1633 | // for what it returns rather than for the message built from it. | ||
| 1634 | func (s *Store) SpecForHost(hostID string) (uint64, []VM, error) { | ||
| 1628 | tx, err := s.db.Begin() | 1635 | tx, err := s.db.Begin() |
| 1629 | if err != nil { | 1636 | if err != nil { |
| 1630 | return 0, nil, err | 1637 | return 0, nil, err |
internal/server/store/store_test.go
| Old | New | ||
|---|---|---|---|
| @@ -251,8 +251,8 @@ func TestRecordVMHostKeyCertifiesOnlyItsOwnHostsVM(t *testing.T) { | |||
| 251 | require.NoError(t, s.RecordVMHostKey("vm1", h.ID, pubkey, cert)) | 251 | require.NoError(t, s.RecordVMHostKey("vm1", h.ID, pubkey, cert)) |
| 252 | 252 | ||
| 253 | // Both halves must round-trip through the read paths the snapshot and the | 253 | // Both halves must round-trip through the read paths the snapshot and the |
| 254 | // gate rely on: SnapshotForHost (agent-facing) and VMByTenantName. | 254 | // gate rely on: SpecForHost (agent-facing) and VMByTenantName. |
| 255 | _, vms, err := s.SnapshotForHost(h.ID) | 255 | _, vms, err := s.SpecForHost(h.ID) |
| 256 | require.NoError(t, err) | 256 | require.NoError(t, err) |
| 257 | require.Len(t, vms, 1) | 257 | require.Len(t, vms, 1) |
| 258 | assert.Equal(t, pubkey, vms[0].SSHHostPubKey) | 258 | assert.Equal(t, pubkey, vms[0].SSHHostPubKey) |
| @@ -652,13 +652,13 @@ func TestNameUniqueAmongLiveRowsOnly(t *testing.T) { | |||
| 652 | assert.NoError(t, s.CreateVM(mk("vm3")), "tombstoned row must not block the name") | 652 | assert.NoError(t, s.CreateVM(mk("vm3")), "tombstoned row must not block the name") |
| 653 | } | 653 | } |
| 654 | 654 | ||
| 655 | func TestSnapshotForHostIncludesTombstonedAndEpochConsistently(t *testing.T) { | 655 | func TestSpecForHostIncludesTombstonedAndEpochConsistently(t *testing.T) { |
| 656 | s := newStore(t) | 656 | s := newStore(t) |
| 657 | h := enrollHost(t, s) | 657 | h := enrollHost(t, s) |
| 658 | require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", | 658 | require.NoError(t, s.CreateVM(VM{ID: "vm1", HostID: h.ID, Name: "a", ImageURL: "u", |
| 659 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) | 659 | ImageSHA256: "s", VCPUs: 1, MemMB: 512, DiskGB: 5, PowerState: "running"})) |
| 660 | require.NoError(t, s.TombstoneVM("vm1")) | 660 | require.NoError(t, s.TombstoneVM("vm1")) |
| 661 | epoch, vms, err := s.SnapshotForHost(h.ID) | 661 | epoch, vms, err := s.SpecForHost(h.ID) |
| 662 | require.NoError(t, err) | 662 | require.NoError(t, err) |
| 663 | e, _ := s.Epoch() | 663 | e, _ := s.Epoch() |
| 664 | assert.Equal(t, e, epoch) | 664 | assert.Equal(t, e, epoch) |
internal/server/syncsvc/syncsvc.go
| Old | New | ||
|---|---|---|---|
| @@ -326,7 +326,7 @@ func (s *Service) handleConn(ctx context.Context, conn quic.Connection) { | |||
| 326 | // buildSnapshot reads hostID's desired state — its VMs and the exposures it | 326 | // buildSnapshot reads hostID's desired state — its VMs and the exposures it |
| 327 | // should be serving — and renders it as one snapshot. | 327 | // should be serving — and renders it as one snapshot. |
| 328 | func (s *Service) buildSnapshot(hostID string) (*pb.Snapshot, error) { | 328 | func (s *Service) buildSnapshot(hostID string) (*pb.Snapshot, error) { |
| 329 | epoch, vms, err := s.st.SnapshotForHost(hostID) | 329 | epoch, vms, err := s.st.SpecForHost(hostID) |
| 330 | if err != nil { | 330 | if err != nil { |
| 331 | return nil, fmt.Errorf("desired for host: %w", err) | 331 | return nil, fmt.Errorf("desired for host: %w", err) |
| 332 | } | 332 | } |
| @@ -647,8 +647,9 @@ func (s *Service) applyReport(hostID string, rep *pb.Report) { | |||
| 647 | } | 647 | } |
| 648 | } | 648 | } |
| 649 | 649 | ||
| 650 | // toRegistryVMs maps reported VMStatus rows to registry rows. Returns nil (not an | 650 | // toRegistryVMs maps reported VMStatus rows to registry rows. Returns nil (not |
| 651 | // empty slice) for empty input, matching the original append-into-nil behavior. | 651 | // an empty slice) for empty input, matching the original append-into-nil |
| 652 | // behavior. | ||
| 652 | func toRegistryVMs(in []*pb.VMStatus) []registry.VMStatus { | 653 | func toRegistryVMs(in []*pb.VMStatus) []registry.VMStatus { |
| 653 | if len(in) == 0 { | 654 | if len(in) == 0 { |
| 654 | return nil | 655 | return nil |
internal/transport/wirecompat_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,247 @@ | |||
| 1 | package transport | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "encoding/hex" | ||
| 5 | "fmt" | ||
| 6 | "testing" | ||
| 7 | |||
| 8 | "github.com/a73x/eitri/internal/pb" | ||
| 9 | "github.com/stretchr/testify/assert" | ||
| 10 | "github.com/stretchr/testify/require" | ||
| 11 | "google.golang.org/protobuf/proto" | ||
| 12 | "google.golang.org/protobuf/reflect/protoreflect" | ||
| 13 | ) | ||
| 14 | |||
| 15 | // The renames in "wire: the spec/status vocabulary the loop always meant" | ||
| 16 | // (VMDesired -> VMSpec, ActualVM -> VMStatus, ExposureDesired/Actual -> | ||
| 17 | // ExposureSpec/Status, power -> power_state, and the two envelopes) rested on | ||
| 18 | // an argument: protobuf serialises field numbers, not names, so an agent built | ||
| 19 | // against the old .proto still talks to a server built against the new one. | ||
| 20 | // fieldnumbers_test.go locks the numbers, but both halves of every round-trip | ||
| 21 | // there are built from TODAY's descriptors — a coordinated rename plus renumber | ||
| 22 | // plus table edit would pass it, and every other test in this package, while | ||
| 23 | // breaking every fielded agent. | ||
| 24 | // | ||
| 25 | // These fixtures close that. They are real wire bytes, produced from the | ||
| 26 | // descriptor set of proto/eitri/v1/sync.proto AS IT STOOD AT TAG v0.0.7 — the | ||
| 27 | // last release before the rename — with every field in the tree populated, and | ||
| 28 | // asserted here to decode into today's generated types losing nothing. | ||
| 29 | // | ||
| 30 | // They are a CAPTURE, not a golden file: never regenerate them to make a test | ||
| 31 | // pass. A diff here is the wire moving under a fielded agent, which is the one | ||
| 32 | // thing this package exists to prevent. Adding a field is invisible to them by | ||
| 33 | // design — old bytes simply do not carry it — but moving or reusing a number is | ||
| 34 | // not, and neither is narrowing a type. | ||
| 35 | // | ||
| 36 | // Reproducing the capture, to audit it rather than to update it: build a | ||
| 37 | // FileDescriptorSet from `git show v0.0.7:proto/eitri/v1/sync.proto`, populate | ||
| 38 | // every field of eitri.v1.AgentMessage{report} / ServerMessage{snapshot} | ||
| 39 | // through dynamicpb with the values below (strings are the field's full name | ||
| 40 | // plus "#<index>", int64 is 1000+10*number, uint64 2000+10*number, doubles | ||
| 41 | // number+0.5, bytes the field's full name, bools true, repeated fields two | ||
| 42 | // elements), and marshal deterministically. | ||
| 43 | |||
| 44 | // v007ReportFrame is a v0.0.7 AgentMessage{ActualStateReport}, every field set. | ||
| 45 | const v007ReportFrame = "" + | ||
| 46 | "12F8080AF1010A1965697472692E76312E41637475616C564D2E766D5F696423" + | ||
| 47 | "30121965697472692E76312E41637475616C564D2E706F77657223301A196569" + | ||
| 48 | "7472692E76312E41637475616C564D2E70686173652330221665697472692E76" + | ||
| 49 | "312E41637475616C564D2E697023302A1E65697472692E76312E41637475616C" + | ||
| 50 | "564D2E6C6173745F6572726F722330322365697472692E76312E41637475616C" + | ||
| 51 | "564D2E7373685F686F73745F7075626B657923303A2165697472692E76312E41" + | ||
| 52 | "637475616C564D2E7374617475735F64657461696C2330421E65697472692E76" + | ||
| 53 | "312E41637475616C564D2E6E6574776F726B5F697023300AF1010A1965697472" + | ||
| 54 | "692E76312E41637475616C564D2E766D5F69642330121965697472692E76312E" + | ||
| 55 | "41637475616C564D2E706F77657223301A1965697472692E76312E4163747561" + | ||
| 56 | "6C564D2E70686173652330221665697472692E76312E41637475616C564D2E69" + | ||
| 57 | "7023302A1E65697472692E76312E41637475616C564D2E6C6173745F6572726F" + | ||
| 58 | "722330322365697472692E76312E41637475616C564D2E7373685F686F73745F" + | ||
| 59 | "7075626B657923303A2165697472692E76312E41637475616C564D2E73746174" + | ||
| 60 | "75735F64657461696C2330421E65697472692E76312E41637475616C564D2E6E" + | ||
| 61 | "6574776F726B5F69702330122665697472692E76312E41637475616C53746174" + | ||
| 62 | "655265706F72742E64657374726F7965642330122665697472692E76312E4163" + | ||
| 63 | "7475616C53746174655265706F72742E64657374726F79656423311A660A1E65" + | ||
| 64 | "697472692E76312E51756172616E74696E6564564D2E766D5F69642330121D65" + | ||
| 65 | "697472692E76312E51756172616E74696E6564564D2E6E616D6523301A226569" + | ||
| 66 | "7472692E76312E51756172616E74696E6564564D2E766D737065635F6A736F6E" + | ||
| 67 | "2090081A660A1E65697472692E76312E51756172616E74696E6564564D2E766D" + | ||
| 68 | "5F69642330121D65697472692E76312E51756172616E74696E6564564D2E6E61" + | ||
| 69 | "6D6523301A2265697472692E76312E51756172616E74696E6564564D2E766D73" + | ||
| 70 | "7065635F6A736F6E209008220908F20710FC071886082801308C103A2A08F207" + | ||
| 71 | "10FC07188608210000000000001240290000000000001640310000000000001A" + | ||
| 72 | "4038AE0840B808422765697472692E76312E41637475616C5374617465526570" + | ||
| 73 | "6F72742E67756573745F6369647223304A6C0A1C65697472692E76312E457870" + | ||
| 74 | "6F7375726541637475616C2E69642330121F65697472692E76312E4578706F73" + | ||
| 75 | "75726541637475616C2E737461746523301A2065697472692E76312E4578706F" + | ||
| 76 | "7375726541637475616C2E726561736F6E2330220908F20710FC071886084A6C" + | ||
| 77 | "0A1C65697472692E76312E4578706F7375726541637475616C2E69642330121F" + | ||
| 78 | "65697472692E76312E4578706F7375726541637475616C2E737461746523301A" + | ||
| 79 | "2065697472692E76312E4578706F7375726541637475616C2E726561736F6E23" + | ||
| 80 | "30220908F20710FC07188608522D65697472692E76312E41637475616C537461" + | ||
| 81 | "74655265706F72742E686F73745F75706C696E6B5F616464722330" | ||
| 82 | |||
| 83 | // v007SnapshotFrame is a v0.0.7 ServerMessage{DesiredStateSnapshot}, every | ||
| 84 | // field set. | ||
| 85 | const v007SnapshotFrame = "" + | ||
| 86 | "0A850908DA0F12A0030A1A65697472692E76312E564D446573697265642E766D" + | ||
| 87 | "5F69642330121965697472692E76312E564D446573697265642E6E616D652330" + | ||
| 88 | "1A1E65697472692E76312E564D446573697265642E696D6167655F75726C2330" + | ||
| 89 | "222165697472692E76312E564D446573697265642E696D6167655F7368613235" + | ||
| 90 | "3623302A1F65697472692E76312E564D446573697265642E636C6F75645F696E" + | ||
| 91 | "6974233030A40838AE0840B8084801522065697472692E76312E564D44657369" + | ||
| 92 | "7265642E706F7765725F737461746523305801622765697472692E76312E564D" + | ||
| 93 | "446573697265642E7373685F617574686F72697A65645F6B657923308A012265" + | ||
| 94 | "697472692E76312E564D446573697265642E7373685F686F73745F6365727423" + | ||
| 95 | "3092013065697472692E76312E564D446573697265642E7373685F757365725F" + | ||
| 96 | "63615F617574686F72697A65645F6B657973233092013065697472692E76312E" + | ||
| 97 | "564D446573697265642E7373685F757365725F63615F617574686F72697A6564" + | ||
| 98 | "5F6B6579732331980101A2011C65697472692E76312E564D446573697265642E" + | ||
| 99 | "6E6574776F726B233012A0030A1A65697472692E76312E564D44657369726564" + | ||
| 100 | "2E766D5F69642330121965697472692E76312E564D446573697265642E6E616D" + | ||
| 101 | "6523301A1E65697472692E76312E564D446573697265642E696D6167655F7572" + | ||
| 102 | "6C2330222165697472692E76312E564D446573697265642E696D6167655F7368" + | ||
| 103 | "6132353623302A1F65697472692E76312E564D446573697265642E636C6F7564" + | ||
| 104 | "5F696E6974233030A40838AE0840B8084801522065697472692E76312E564D44" + | ||
| 105 | "6573697265642E706F7765725F737461746523305801622765697472692E7631" + | ||
| 106 | "2E564D446573697265642E7373685F617574686F72697A65645F6B657923308A" + | ||
| 107 | "012265697472692E76312E564D446573697265642E7373685F686F73745F6365" + | ||
| 108 | "7274233092013065697472692E76312E564D446573697265642E7373685F7573" + | ||
| 109 | "65725F63615F617574686F72697A65645F6B657973233092013065697472692E" + | ||
| 110 | "76312E564D446573697265642E7373685F757365725F63615F617574686F7269" + | ||
| 111 | "7A65645F6B6579732331980101A2011C65697472692E76312E564D4465736972" + | ||
| 112 | "65642E6E6574776F726B23301A5E0A1F65697472692E76312E4167656E745570" + | ||
| 113 | "67726164652E76657273696F6E2330121B65697472692E76312E4167656E7455" + | ||
| 114 | "7067726164652E75726C23301A1E65697472692E76312E4167656E7455706772" + | ||
| 115 | "6164652E7368613235362330226C0A1D65697472692E76312E4578706F737572" + | ||
| 116 | "65446573697265642E69642330122065697472692E76312E4578706F73757265" + | ||
| 117 | "446573697265642E766D5F6964233018AE0320B8032A2365697472692E76312E" + | ||
| 118 | "4578706F73757265446573697265642E70726F746F636F6C2330226C0A1D6569" + | ||
| 119 | "7472692E76312E4578706F73757265446573697265642E696423301220656974" + | ||
| 120 | "72692E76312E4578706F73757265446573697265642E766D5F6964233018AE03" + | ||
| 121 | "20B8032A2365697472692E76312E4578706F73757265446573697265642E7072" + | ||
| 122 | "6F746F636F6C2330" | ||
| 123 | |||
| 124 | func TestV007AgentFrameDecodesIntoTodaysReport(t *testing.T) { | ||
| 125 | var m pb.AgentMessage | ||
| 126 | require.NoError(t, proto.Unmarshal(mustHex(t, v007ReportFrame), &m)) | ||
| 127 | |||
| 128 | r := m.GetReport() | ||
| 129 | require.NotNil(t, r, "a v0.0.7 report must still arrive in the report branch of the oneof") | ||
| 130 | assertNoUnknownFields(t, m.ProtoReflect(), "AgentMessage") | ||
| 131 | |||
| 132 | // The renamed message: what v0.0.7 called ActualVM.power arrives verbatim in | ||
| 133 | // VMStatus.power_state, because both are field 2 of the same message. | ||
| 134 | require.Len(t, r.GetVms(), 2) | ||
| 135 | vm := r.GetVms()[0] | ||
| 136 | assert.Equal(t, "eitri.v1.ActualVM.vm_id#0", vm.GetVmId()) | ||
| 137 | assert.Equal(t, "eitri.v1.ActualVM.power#0", vm.GetPowerState()) | ||
| 138 | assert.Equal(t, "eitri.v1.ActualVM.phase#0", vm.GetPhase()) | ||
| 139 | assert.Equal(t, "eitri.v1.ActualVM.ip#0", vm.GetIp()) | ||
| 140 | assert.Equal(t, "eitri.v1.ActualVM.last_error#0", vm.GetLastError()) | ||
| 141 | assert.Equal(t, "eitri.v1.ActualVM.ssh_host_pubkey#0", vm.GetSshHostPubkey()) | ||
| 142 | assert.Equal(t, "eitri.v1.ActualVM.status_detail#0", vm.GetStatusDetail()) | ||
| 143 | assert.Equal(t, "eitri.v1.ActualVM.network_ip#0", vm.GetNetworkIp()) | ||
| 144 | |||
| 145 | // The renamed envelope's own fields. | ||
| 146 | assert.Equal(t, "eitri.v1.ActualStateReport.guest_cidr#0", r.GetGuestCidr()) | ||
| 147 | assert.Equal(t, "eitri.v1.ActualStateReport.host_uplink_addr#0", r.GetHostUplinkAddr()) | ||
| 148 | assert.True(t, r.GetFenceViolation()) | ||
| 149 | assert.Equal(t, uint64(2060), r.GetLastSeenEpoch()) | ||
| 150 | require.NotNil(t, r.GetCapacity()) | ||
| 151 | assert.Equal(t, int64(1010), r.GetCapacity().GetVcpus()) | ||
| 152 | require.NotNil(t, r.GetMetrics()) | ||
| 153 | assert.InEpsilon(t, 4.5, r.GetMetrics().GetLoad1(), 1e-9) | ||
| 154 | require.Len(t, r.GetQuarantined(), 2) | ||
| 155 | assert.Equal(t, []byte("eitri.v1.QuarantinedVM.vmspec_json"), r.GetQuarantined()[0].GetVmspecJson()) | ||
| 156 | assert.Len(t, r.GetDestroyed(), 2) | ||
| 157 | |||
| 158 | // ExposureActual -> ExposureStatus, sessions still a nested message. | ||
| 159 | require.Len(t, r.GetExposures(), 2) | ||
| 160 | ex := r.GetExposures()[0] | ||
| 161 | assert.Equal(t, "eitri.v1.ExposureActual.id#0", ex.GetId()) | ||
| 162 | assert.Equal(t, "eitri.v1.ExposureActual.state#0", ex.GetState()) | ||
| 163 | require.NotNil(t, ex.GetSessions(), "presence must survive: nil sessions means no counters, not zero counters") | ||
| 164 | assert.Equal(t, int64(1010), ex.GetSessions().GetActive()) | ||
| 165 | } | ||
| 166 | |||
| 167 | func TestV007ServerFrameDecodesIntoTodaysSnapshot(t *testing.T) { | ||
| 168 | var m pb.ServerMessage | ||
| 169 | require.NoError(t, proto.Unmarshal(mustHex(t, v007SnapshotFrame), &m)) | ||
| 170 | |||
| 171 | s := m.GetSnapshot() | ||
| 172 | require.NotNil(t, s, "a v0.0.7 snapshot must still arrive in the snapshot branch of the oneof") | ||
| 173 | assertNoUnknownFields(t, m.ProtoReflect(), "ServerMessage") | ||
| 174 | |||
| 175 | assert.Equal(t, uint64(2010), s.GetEpoch()) | ||
| 176 | require.Len(t, s.GetVms(), 2) | ||
| 177 | vm := s.GetVms()[0] | ||
| 178 | // VMDesired.power_state was already spelled right; it is field 10 either way. | ||
| 179 | assert.Equal(t, "eitri.v1.VMDesired.vm_id#0", vm.GetVmId()) | ||
| 180 | assert.Equal(t, "eitri.v1.VMDesired.power_state#0", vm.GetPowerState()) | ||
| 181 | assert.Equal(t, "eitri.v1.VMDesired.network#0", vm.GetNetwork()) | ||
| 182 | assert.Equal(t, "eitri.v1.VMDesired.ssh_host_cert#0", vm.GetSshHostCert()) | ||
| 183 | assert.True(t, vm.GetHostCertRequired()) | ||
| 184 | assert.True(t, vm.GetTombstoned()) | ||
| 185 | assert.True(t, vm.GetPersistent(), "field 9 still means persistent on a v0.0.7 wire") | ||
| 186 | assert.Len(t, vm.GetSshUserCaAuthorizedKeys(), 2) | ||
| 187 | |||
| 188 | require.NotNil(t, s.GetAgentUpgrade()) | ||
| 189 | require.Len(t, s.GetExposures(), 2) | ||
| 190 | assert.Equal(t, "eitri.v1.ExposureDesired.id#0", s.GetExposures()[0].GetId()) | ||
| 191 | } | ||
| 192 | |||
| 193 | // A field the current schema does not claim lands in the unknown-fields set | ||
| 194 | // rather than failing to parse, so silence there is the real assertion: every | ||
| 195 | // number a v0.0.7 peer writes is still a number this build knows, at the same | ||
| 196 | // wire type. Recurses, because a renumber three messages deep parses fine at | ||
| 197 | // the top. | ||
| 198 | func assertNoUnknownFields(t *testing.T, m protoreflect.Message, path string) { | ||
| 199 | t.Helper() | ||
| 200 | if u := m.GetUnknown(); len(u) > 0 { | ||
| 201 | t.Errorf("%s carries %d bytes this build does not recognise (%x) — a field number moved or was reused", path, len(u), []byte(u)) | ||
| 202 | } | ||
| 203 | m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { | ||
| 204 | switch { | ||
| 205 | case fd.IsMap(): | ||
| 206 | case fd.Kind() != protoreflect.MessageKind && fd.Kind() != protoreflect.GroupKind: | ||
| 207 | case fd.IsList(): | ||
| 208 | l := v.List() | ||
| 209 | for i := range l.Len() { | ||
| 210 | assertNoUnknownFields(t, l.Get(i).Message(), fmt.Sprintf("%s.%s[%d]", path, fd.Name(), i)) | ||
| 211 | } | ||
| 212 | default: | ||
| 213 | assertNoUnknownFields(t, v.Message(), path+"."+string(fd.Name())) | ||
| 214 | } | ||
| 215 | return true | ||
| 216 | }) | ||
| 217 | } | ||
| 218 | |||
| 219 | // Re-encoding what a v0.0.7 peer sent must reproduce it byte for byte. Field | ||
| 220 | // numbers, wire types, ordering and repeated-element count all have to match, | ||
| 221 | // which catches the losses the getters above are too specific to see: a field | ||
| 222 | // this build no longer defines, or one whose type changed under it. | ||
| 223 | func TestV007FramesReEncodeByteIdentically(t *testing.T) { | ||
| 224 | for _, tc := range []struct { | ||
| 225 | name string | ||
| 226 | hex string | ||
| 227 | msg proto.Message | ||
| 228 | }{ | ||
| 229 | {"report", v007ReportFrame, &pb.AgentMessage{}}, | ||
| 230 | {"snapshot", v007SnapshotFrame, &pb.ServerMessage{}}, | ||
| 231 | } { | ||
| 232 | t.Run(tc.name, func(t *testing.T) { | ||
| 233 | want := mustHex(t, tc.hex) | ||
| 234 | require.NoError(t, proto.Unmarshal(want, tc.msg)) | ||
| 235 | got, err := proto.MarshalOptions{Deterministic: true}.Marshal(tc.msg) | ||
| 236 | require.NoError(t, err) | ||
| 237 | assert.Equal(t, want, got, "re-encoding a v0.0.7 frame changed it") | ||
| 238 | }) | ||
| 239 | } | ||
| 240 | } | ||
| 241 | |||
| 242 | func mustHex(t *testing.T, s string) []byte { | ||
| 243 | t.Helper() | ||
| 244 | b, err := hex.DecodeString(s) | ||
| 245 | require.NoError(t, err) | ||
| 246 | return b | ||
| 247 | } | ||
proto/eitri/v1/sync.proto
| Old | New | ||
|---|---|---|---|
| @@ -75,7 +75,9 @@ message HostMetrics { | |||
| 75 | // travels back in a Report, and the control plane holds both on one row. | 75 | // travels back in a Report, and the control plane holds both on one row. |
| 76 | message VMStatus { | 76 | message VMStatus { |
| 77 | string vm_id = 1; | 77 | string vm_id = 1; |
| 78 | string power_state = 2; // "running"|"stopped" as observed; VMSpec.power_state is what was asked for | 78 | // As observed on the host, never as asked for: VMSpec.power_state carries |
| 79 | // the request, and the two disagreeing is what converge exists to fix. | ||
| 80 | string power_state = 2; // "running"|"stopped" | ||
| 79 | string phase = 3; // "creating"|"ready"|"failed"|"quarantined" | 81 | string phase = 3; // "creating"|"ready"|"failed"|"quarantined" |
| 80 | string ip = 4; // the guest's address on its host's NAT underlay — every guest has one, from boot | 82 | string ip = 4; // the guest's address on its host's NAT underlay — every guest has one, from boot |
| 81 | string last_error = 5; | 83 | string last_error = 5; |
| @@ -195,7 +197,7 @@ message VMSpec { | |||
| 195 | // Snapshot is the FULL spec for one host; the agent converges toward it. | 197 | // Snapshot is the FULL spec for one host; the agent converges toward it. |
| 196 | message Snapshot { | 198 | message Snapshot { |
| 197 | uint64 epoch = 1; // agents refuse epoch < highest seen | 199 | uint64 epoch = 1; // agents refuse epoch < highest seen |
| 198 | repeated VMSpec vms = 2; // FULL set for this host, including tombstoned | 200 | repeated VMSpec vms = 2; // FULL set for this host, including tombstoned |
| 199 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade | 201 | AgentUpgrade agent_upgrade = 3; // optional operator-initiated agent self-upgrade |
| 200 | repeated ExposureSpec exposures = 4; // FULL set for this host | 202 | repeated ExposureSpec exposures = 4; // FULL set for this host |
| 201 | } | 203 | } |