a73x

7dd41af7

wire: what the rename argued, the tree now enforces

a73x   2026-08-23 05:47

Commit message
wire: what the rename argued, the tree now enforces

The spec/status rename rested on one claim — protobuf serialises field
numbers, not names, so a v0.0.7 agent talks to this server unchanged — and
that claim was prose. Nothing in the tree stopped a debug endpoint reaching
for protojson tomorrow, at which point the names already on someone's disk
become a contract and the next rename breaks it silently; and nothing held
bytes an older release actually put on the wire, so the compatibility half
was checked only against today's descriptors.

R15 bans protojson across the module, in internal/arch and in depguard, with
the assumption written down where the falsifier can find it. internal/transport
carries two captures built from the descriptor set at tag v0.0.7 — a full
AgentMessage{ActualStateReport} and ServerMessage{DesiredStateSnapshot} — and
proves they decode into today's types with no unknown field at any depth and
re-encode byte for byte. They caught a deliberate renumber three messages
deep. They are a capture, not a golden file: never regenerated to pass.

Two gates stop failing on trees with nothing wrong in them. proto-check
generates into a throwaway tree and diffs that, which is the actual invariant
and is independent of the index — the old form failed every in-progress .proto
change, including a correct one. cover depends on a staged SPA, because
internal/server/web's floor is measured against the real dist and reads 89%
against the committed placeholder — which is every fresh clone. The
prerequisite is the staged index.html rather than the web target, so a dist
already present is not rebuilt for an `npm ci` nobody asked for.

The rest is the vocabulary finishing its own sweep: the README diagram was
the last place the old names survived, store.DesiredForHost had become
SnapshotForHost and read as a scoped Snapshot it is not (SpecForHost, with
the difference documented), and the loop keeps the adjective the messages
gave up — said once in decisions.md and architecture.md so the next reader
does not "finish" a rename that is deliberately half-done.

Issue: 104a84d9

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