2823c631
feat(arch): every crossing is owned — R7 through R10
a73x 2026-07-26 20:08
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -52,22 +52,6 @@ proto: | |||
| 52 | protoc --go_out=. --go_opt=module=github.com/a73x/eitri \ | 52 | protoc --go_out=. --go_opt=module=github.com/a73x/eitri \ |
| 53 | proto/eitri/v1/sync.proto | 53 | proto/eitri/v1/sync.proto |
| 54 | 54 | ||
| 55 | # Regenerate the API contract artifacts: docs/openapi.json from the route | ||
| 56 | # table, and the TypeScript types from the spec (needs the web toolchain, | ||
| 57 | # like `make web`). | ||
| 58 | api: | ||
| 59 | go run ./cmd/eitri-apispec | ||
| 60 | @if [ -x web/node_modules/.bin/openapi-typescript ]; then \ | ||
| 61 | cd web && npm run gen:api; \ | ||
| 62 | else \ | ||
| 63 | echo "api: openapi-typescript not installed (run 'make web') — skipping TS type generation (enforced in CI)"; \ | ||
| 64 | fi | ||
| 65 | |||
| 66 | # Merge gate: the committed spec and TS types must match the route table. | ||
| 67 | api-check: | ||
| 68 | @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \ | ||
| 69 | { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; } | ||
| 70 | |||
| 71 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote | 55 | # Roll freshly-built HEAD to the live fleet: local eitri-server + every remote |
| 72 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from | 56 | # eitri-agent (restart-based; running VMs survive the agent bounce). Config from |
| 73 | # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example. | 57 | # $$EITRI_DEPLOY_ENV (default ~/eitri-deploy/deploy.env); see scripts/deploy.env.example. |
| @@ -134,6 +118,22 @@ shape-check: | |||
| 134 | git diff --exit-code docs/shape.json docs/shape.html || \ | 118 | git diff --exit-code docs/shape.json docs/shape.html || \ |
| 135 | { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; } | 119 | { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; } |
| 136 | 120 | ||
| 121 | # Regenerate the API contract artifacts: docs/openapi.json from the route | ||
| 122 | # table, and the TypeScript types from the spec (needs the web toolchain, | ||
| 123 | # like `make web`). | ||
| 124 | api: | ||
| 125 | go run ./cmd/eitri-apispec | ||
| 126 | @if [ -x web/node_modules/.bin/openapi-typescript ]; then \ | ||
| 127 | cd web && npm run gen:api; \ | ||
| 128 | else \ | ||
| 129 | echo "api: openapi-typescript not installed (run 'make web') — skipping TS type generation (enforced in CI)"; \ | ||
| 130 | fi | ||
| 131 | |||
| 132 | # Merge gate: the committed spec and TS types must match the route table. | ||
| 133 | api-check: | ||
| 134 | @$(MAKE) api && git diff --exit-code -- docs/openapi.json web/src/lib/api-types.ts || \ | ||
| 135 | { echo "api-check: API contract artifacts are stale — run 'make api'"; exit 1; } | ||
| 136 | |||
| 137 | # Generate the eitri.sh static site into site/dist. Picks up the newest | 137 | # Generate the eitri.sh static site into site/dist. Picks up the newest |
| 138 | # dist/<version> (from `make release`) for the downloads page when one | 138 | # dist/<version> (from `make release`) for the downloads page when one |
| 139 | # exists; renders a docs-only preview otherwise. | 139 | # exists; renders a docs-only preview otherwise. |
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -55,6 +55,20 @@ fleet-only, and no principal crosses tenant boundaries. Instead of a global | |||
| 55 | admin identity. Cross-tenant capability is the thing multi-tenancy exists to | 55 | admin identity. Cross-tenant capability is the thing multi-tenancy exists to |
| 56 | prevent, so it is unrepresentable rather than merely forbidden. | 56 | prevent, so it is unrepresentable rather than merely forbidden. |
| 57 | 57 | ||
| 58 | ### One owner per API surface | ||
| 59 | |||
| 60 | Each capability has exactly one package that owns its surface, enforced by | ||
| 61 | fitness functions wherever the import graph can see it: SQLite belongs to | ||
| 62 | internal/server/store (R10), process execution to the R7 sanctioned set, the | ||
| 63 | wire types to the leaf plane's one shared contract (R9), the client's ssh to | ||
| 64 | internal/cli, importable only by its binary (R8), and the server's HTTP API | ||
| 65 | to internal/server/api/client over the internal/server/api/types leaf | ||
| 66 | (R11/R12), with the generated OpenAPI spec drift-gated by api-check. Instead | ||
| 67 | of convention plus review, which let three parallel HTTP clients of the same | ||
| 68 | endpoints accumulate before anyone noticed. When a second implementation of a | ||
| 69 | surface appears, the fix is consolidation behind the owner, not a second | ||
| 70 | owner. | ||
| 71 | |||
| 58 | ### Agents pin the server cert by fingerprint | 72 | ### Agents pin the server cert by fingerprint |
| 59 | 73 | ||
| 60 | Agents trust the server's self-signed QUIC cert by fingerprint from the join | 74 | Agents trust the server's self-signed QUIC cert by fingerprint from the join |
docs/openapi.json
| Old | New | ||
|---|---|---|---|
| @@ -732,6 +732,15 @@ | |||
| 732 | "schema": { | 732 | "schema": { |
| 733 | "type": "string" | 733 | "type": "string" |
| 734 | } | 734 | } |
| 735 | }, | ||
| 736 | { | ||
| 737 | "description": "purge VM rows and remove the host immediately (dead hardware escape hatch)", | ||
| 738 | "in": "query", | ||
| 739 | "name": "force", | ||
| 740 | "required": false, | ||
| 741 | "schema": { | ||
| 742 | "type": "string" | ||
| 743 | } | ||
| 735 | } | 744 | } |
| 736 | ], | 745 | ], |
| 737 | "responses": { | 746 | "responses": { |
| @@ -754,7 +763,7 @@ | |||
| 754 | "adminToken": [] | 763 | "adminToken": [] |
| 755 | } | 764 | } |
| 756 | ], | 765 | ], |
| 757 | "summary": "Decommission a host: tombstone its VMs and drain gracefully (202)." | 766 | "summary": "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200." |
| 758 | } | 767 | } |
| 759 | }, | 768 | }, |
| 760 | "/api/v1/hosts/{id}/revoke-credential": { | 769 | "/api/v1/hosts/{id}/revoke-credential": { |
internal/arch/arch_test.go
| Old | New | ||
|---|---|---|---|
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | "sort" | 5 | "sort" |
| 6 | "strings" | 6 | "strings" |
| 7 | "testing" | 7 | "testing" |
| 8 | |||
| 9 | "github.com/a73x/eitri/internal/shape" | ||
| 8 | ) | 10 | ) |
| 9 | 11 | ||
| 10 | // module is the import-path prefix shared by every package in this repo. | 12 | // module is the import-path prefix shared by every package in this repo. |
| @@ -106,18 +108,60 @@ func TestControlAndDataPlaneAreDisjoint(t *testing.T) { | |||
| 106 | } | 108 | } |
| 107 | } | 109 | } |
| 108 | 110 | ||
| 109 | // R3: the wire contract is the only thing shared across the two planes, so it | 111 | // R9: every wire-plane package is a leaf — it may import no other internal |
| 110 | // must stay dependency-light and leaf-like. internal/pb (generated protobuf) | 112 | // package. The wire plane is the only code shared across the control and |
| 111 | // and internal/transport (framing + TLS) must import no other internal package | 113 | // data planes, so a single heavy dependency there (the store, the transport |
| 112 | // — otherwise a heavy dependency (e.g. the SQLite store) would leak across the | 114 | // stack) would leak into both binaries at once. Generalizes R3's hardcoded |
| 113 | // plane boundary into both binaries. | 115 | // pb/transport pair: membership comes from the shape classifier, so a new |
| 114 | func TestWireContractIsLeaf(t *testing.T) { | 116 | // wire package inherits the rule the moment it's classified. |
| 115 | g := internalImports(t) | 117 | func TestWirePlaneIsLeaf(t *testing.T) { |
| 116 | for _, leaf := range []string{module + "/internal/pb", module + "/internal/transport"} { | 118 | // directImports (not internalImports) so leaf packages with zero internal |
| 117 | for _, d := range g[leaf] { | 119 | // deps still show up as keys — internalImports only carries packages that |
| 118 | t.Errorf("%s must not import any internal package, but imports %s", short(leaf), short(d)) | 120 | // already have at least one internal edge, which would silently drop the |
| 121 | // very packages this rule exists to sweep. | ||
| 122 | g := directImports(t) | ||
| 123 | swept := 0 | ||
| 124 | for pkg, deps := range g { | ||
| 125 | rel := strings.TrimPrefix(pkg, module+"/") | ||
| 126 | if shape.Classify(rel) != shape.PlaneWire { | ||
| 127 | continue | ||
| 128 | } | ||
| 129 | swept++ | ||
| 130 | for _, d := range deps { | ||
| 131 | if strings.HasPrefix(d, module+"/") { | ||
| 132 | t.Errorf("wire package %s must not import any internal package, but imports %s", short(pkg), short(d)) | ||
| 133 | } | ||
| 134 | } | ||
| 135 | } | ||
| 136 | if swept < 8 { | ||
| 137 | t.Errorf("wire-plane sweep saw only %d packages — classifier drift? (expect pb, transport, joinblob, cloudinit, names, random, version, relmanifest)", swept) | ||
| 138 | } | ||
| 139 | } | ||
| 140 | |||
| 141 | // R10: modernc.org/sqlite is imported by internal/server/store and nowhere | ||
| 142 | // else. The store owns schema application and the single-connection write | ||
| 143 | // serialization; a second driver import would bypass both silently. The | ||
| 144 | // database/sql package itself stays free (sql.ErrNoRows is an idiomatic | ||
| 145 | // sentinel for store callers). | ||
| 146 | func TestSQLiteDriverIsStoresAlone(t *testing.T) { | ||
| 147 | g := directImports(t) | ||
| 148 | const driver = "modernc.org/sqlite" | ||
| 149 | const owner = module + "/internal/server/store" | ||
| 150 | found := false | ||
| 151 | for pkg, deps := range g { | ||
| 152 | for _, d := range deps { | ||
| 153 | if d != driver { | ||
| 154 | continue | ||
| 155 | } | ||
| 156 | found = true | ||
| 157 | if pkg != owner { | ||
| 158 | t.Errorf("package %s imports %s directly, but only %s may", short(pkg), driver, short(owner)) | ||
| 159 | } | ||
| 119 | } | 160 | } |
| 120 | } | 161 | } |
| 162 | if !found { | ||
| 163 | t.Errorf("sweep found no importer of %s at all — did the driver move or get vendored differently?", driver) | ||
| 164 | } | ||
| 121 | } | 165 | } |
| 122 | 166 | ||
| 123 | // R4: domain/state packages hold pure logic and must not depend on the | 167 | // R4: domain/state packages hold pure logic and must not depend on the |
| @@ -183,10 +227,55 @@ func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) { | |||
| 183 | } | 227 | } |
| 184 | } | 228 | } |
| 185 | 229 | ||
| 186 | // The API contract is consumed through the client. internal/server/api/types | 230 | // R7: external process execution anywhere in internal/ is confined to a |
| 231 | // sanctioned allowlist — cloudhv (the data plane's one exec funnel, R6's | ||
| 232 | // narrower story), cli (interactive ssh must be the real OpenSSH client), and | ||
| 233 | // shape (architecture tooling that shells out to `go list`, the same | ||
| 234 | // introspection internal/arch's own tests do). R2/R6 tell the per-plane | ||
| 235 | // stories; R7 is the whole-tree backstop that makes a new exec site anywhere | ||
| 236 | // an explicit, reviewed decision. | ||
| 237 | func TestExecIsConfinedToSanctionedPackages(t *testing.T) { | ||
| 238 | g := directImports(t) | ||
| 239 | allowed := map[string]bool{ | ||
| 240 | module + "/internal/agent/cloudhv": true, // the data plane's one sanctioned exec funnel (R6's story) | ||
| 241 | module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client | ||
| 242 | module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make | ||
| 243 | } | ||
| 244 | for pkg, offenders := range execViolations(g, module, "internal/", allowed) { | ||
| 245 | for _, o := range offenders { | ||
| 246 | t.Errorf("package %s reaches os/exec via %s — exec anywhere in internal/ requires an explicit allowlist entry (see R7)", short(pkg), short(o)) | ||
| 247 | } | ||
| 248 | } | ||
| 249 | } | ||
| 250 | |||
| 251 | // R8: the client CLI is a leaf of its own binary. internal/cli carries the | ||
| 252 | // exec sanction (R7) and touches the user's machine (~/.ssh); letting a | ||
| 253 | // server, agent, or tooling package import it would smuggle both across a | ||
| 254 | // plane boundary. Only cmd/eitri may import it. | ||
| 255 | func TestClientCLIIsOnlyImportedByItsBinary(t *testing.T) { | ||
| 256 | g := internalImports(t) | ||
| 257 | target := module + "/internal/cli" | ||
| 258 | allowedImporter := module + "/cmd/eitri" | ||
| 259 | for pkg, deps := range g { | ||
| 260 | if pkg == target || pkg == allowedImporter { | ||
| 261 | continue | ||
| 262 | } | ||
| 263 | for _, d := range deps { | ||
| 264 | if d == target { | ||
| 265 | t.Errorf("package %s must not import %s — only %s may", short(pkg), short(target), short(allowedImporter)) | ||
| 266 | } | ||
| 267 | } | ||
| 268 | } | ||
| 269 | } | ||
| 270 | |||
| 271 | // R11: the API contract is consumed through the client. internal/server/api/types | ||
| 187 | // is the wire contract, but no package outside internal/server/* may import it — | 272 | // is the wire contract, but no package outside internal/server/* may import it — |
| 188 | // consumers get the wire structs via internal/server/api/client's re-exported | 273 | // consumers get the wire structs via internal/server/api/client's re-exported |
| 189 | // aliases (client.Host and friends), so the client is the only door. | 274 | // aliases (client.Host and friends), so the client is the only door. This rule |
| 275 | // stops a fourth hand-rolled client at the import graph: the first three | ||
| 276 | // (mcpserver/eitriapi.go, cmd/eitri-smoke/client.go, internal/cli's raw HTTP) | ||
| 277 | // each began as "just import the types and fmt.Sprintf the paths", and each | ||
| 278 | // would have tripped this exact sweep on its first commit. | ||
| 190 | func TestAPIContractIsConsumedThroughTheClient(t *testing.T) { | 279 | func TestAPIContractIsConsumedThroughTheClient(t *testing.T) { |
| 191 | g := internalImports(t) | 280 | g := internalImports(t) |
| 192 | target := module + "/internal/server/api/types" | 281 | target := module + "/internal/server/api/types" |
| @@ -208,7 +297,7 @@ func TestAPIContractIsConsumedThroughTheClient(t *testing.T) { | |||
| 208 | } | 297 | } |
| 209 | } | 298 | } |
| 210 | 299 | ||
| 211 | // internal/server/api/types is a leaf — stdlib imports only. The contract | 300 | // R12: internal/server/api/types is a leaf — stdlib imports only. The contract |
| 212 | // is consumed by the spec generator, the client, and the handlers; a single | 301 | // is consumed by the spec generator, the client, and the handlers; a single |
| 213 | // internal import would drag server internals into every consumer at once and | 302 | // internal import would drag server internals into every consumer at once and |
| 214 | // break the reflection-based OpenAPI generator's "types package = the whole | 303 | // break the reflection-based OpenAPI generator's "types package = the whole |
internal/mcpserver/config.go
| Old | New | ||
|---|---|---|---|
| @@ -1,7 +1,8 @@ | |||
| 1 | // Package mcpserver implements the eitri-mcp server: MCP tools that let a | 1 | // Package mcpserver implements the eitri-mcp server: MCP tools that let a |
| 2 | // model create, control (SSH exec/files), and destroy eitri VMs. It is an API | 2 | // model create, control (SSH exec/files), and destroy eitri VMs. It is an API |
| 3 | // CLIENT of the control plane — it must not import internal/server, and the | 3 | // CLIENT of the control plane — it speaks to it only through the shared API |
| 4 | // admin token it holds must never appear in tool results or errors. | 4 | // client (internal/server/api/client), never any other server internals, and |
| 5 | // the admin token it holds must never appear in tool results or errors. | ||
| 5 | package mcpserver | 6 | package mcpserver |
| 6 | 7 | ||
| 7 | import ( | 8 | import ( |
internal/server/api/api.go
| Old | New | ||
|---|---|---|---|
| @@ -304,13 +304,15 @@ func (a *API) handleCreateEnrollToken(w http.ResponseWriter, r *http.Request) { | |||
| 304 | http.Error(w, "internal error", http.StatusInternalServerError) | 304 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 305 | return | 305 | return |
| 306 | } | 306 | } |
| 307 | writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Token: tok, Join: join}) | 307 | writeJSON(w, http.StatusCreated, types.EnrollTokenResponse{Join: join, Token: tok}) |
| 308 | } | 308 | } |
| 309 | 309 | ||
| 310 | // --- hosts --- | 310 | // --- hosts --- |
| 311 | 311 | ||
| 312 | // toHostResponse merges a durable host row with its live registry state into | 312 | // toHostResponse merges a durable host row with its live registry state into |
| 313 | // the wire shape (types.Host). | 313 | // the wire shape (types.Host). Handler-side facts behind the contract's |
| 314 | // sync-health fields: Stale trips when the last report is older than | ||
| 315 | // registry.StaleWindow; AgentVersion is registry-held from the agent's Hello. | ||
| 314 | func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Alloc) types.Host { | 316 | func toHostResponse(h store.Host, st registry.HostState, ok bool, alloc store.Alloc) types.Host { |
| 315 | hr := types.Host{ | 317 | hr := types.Host{ |
| 316 | ID: h.ID, | 318 | ID: h.ID, |
internal/server/api/client/client.go
| Old | New | ||
|---|---|---|---|
| @@ -117,7 +117,7 @@ func (c *Client) ListHosts(ctx context.Context) ([]Host, error) { | |||
| 117 | return hosts, c.do(ctx, http.MethodGet, "/api/v1/hosts", nil, &hosts) | 117 | return hosts, c.do(ctx, http.MethodGet, "/api/v1/hosts", nil, &hosts) |
| 118 | } | 118 | } |
| 119 | 119 | ||
| 120 | // ListVMs returns every VM the caller can see. | 120 | // ListVMs returns every VM the caller's tenant can see. |
| 121 | func (c *Client) ListVMs(ctx context.Context) ([]VM, error) { | 121 | func (c *Client) ListVMs(ctx context.Context) ([]VM, error) { |
| 122 | var vms []VM | 122 | var vms []VM |
| 123 | return vms, c.do(ctx, http.MethodGet, "/api/v1/vms", nil, &vms) | 123 | return vms, c.do(ctx, http.MethodGet, "/api/v1/vms", nil, &vms) |
internal/server/api/events.go
| Old | New | ||
|---|---|---|---|
| @@ -123,7 +123,8 @@ func parseLimit(w http.ResponseWriter, r *http.Request) (int, bool) { | |||
| 123 | 123 | ||
| 124 | // auditRowsToResponse maps store rows to the wire shape, embedding each detail | 124 | // auditRowsToResponse maps store rows to the wire shape, embedding each detail |
| 125 | // as raw JSON and defensively re-marshalling anything that isn't valid JSON so | 125 | // as raw JSON and defensively re-marshalling anything that isn't valid JSON so |
| 126 | // the endpoint never emits a malformed body. | 126 | // the endpoint never emits a malformed body (detail is always a marshaled |
| 127 | // object — see store.AppendAudit callers). | ||
| 127 | func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent { | 128 | func auditRowsToResponse(rows []store.AuditEntry) []types.AuditEvent { |
| 128 | out := make([]types.AuditEvent, len(rows)) | 129 | out := make([]types.AuditEvent, len(rows)) |
| 129 | for i, e := range rows { | 130 | for i, e := range rows { |
internal/server/api/routes.go
| Old | New | ||
|---|---|---|---|
| @@ -130,7 +130,8 @@ var routeTable = []Route{ | |||
| 130 | Auth: AuthAdmin, | 130 | Auth: AuthAdmin, |
| 131 | Kind: KindJSON, | 131 | Kind: KindJSON, |
| 132 | Success: http.StatusAccepted, | 132 | Success: http.StatusAccepted, |
| 133 | Doc: "Decommission a host: tombstone its VMs and drain gracefully (202).", | 133 | Query: []QueryParam{{Name: "force", Doc: "purge VM rows and remove the host immediately (dead hardware escape hatch)"}}, |
| 134 | Doc: "Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200.", | ||
| 134 | handler: (*API).handleDecommissionHost, | 135 | handler: (*API).handleDecommissionHost, |
| 135 | }, | 136 | }, |
| 136 | { | 137 | { |
internal/server/api/spec/spec_test.go
| Old | New | ||
|---|---|---|---|
| @@ -55,8 +55,8 @@ func TestEveryRouteHasAnOperation(t *testing.T) { | |||
| 55 | t.Errorf("%s %s: no operation in paths", r.Method, r.Path) | 55 | t.Errorf("%s %s: no operation in paths", r.Method, r.Path) |
| 56 | continue | 56 | continue |
| 57 | } | 57 | } |
| 58 | // {id} path params must be declared required string params. | 58 | // {id}/{tenant} path params must be declared required string params. |
| 59 | for _, name := range []string{"id"} { | 59 | for _, name := range []string{"id", "tenant"} { |
| 60 | if !strings.Contains(r.Path, "{"+name+"}") { | 60 | if !strings.Contains(r.Path, "{"+name+"}") { |
| 61 | continue | 61 | continue |
| 62 | } | 62 | } |
| @@ -129,6 +129,14 @@ func TestRequiredArrays(t *testing.T) { | |||
| 129 | t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i]) | 129 | t.Errorf("Host required not sorted: %q before %q", required[i-1], required[i]) |
| 130 | } | 130 | } |
| 131 | } | 131 | } |
| 132 | // Pointer fields are nullable, not required. | ||
| 133 | for _, name := range []string{"last_seen", "seconds_since_last_seen", "metrics"} { | ||
| 134 | for _, r := range required { | ||
| 135 | if r == name { | ||
| 136 | t.Errorf("pointer field %q must not be required", name) | ||
| 137 | } | ||
| 138 | } | ||
| 139 | } | ||
| 132 | } | 140 | } |
| 133 | 141 | ||
| 134 | func TestSecurity(t *testing.T) { | 142 | func TestSecurity(t *testing.T) { |
| @@ -166,6 +174,28 @@ func TestHostSchemaFields(t *testing.T) { | |||
| 166 | if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") { | 174 | if ref := dig(t, props, "capacity", "$ref").(string); !strings.HasSuffix(ref, "Capacity") { |
| 167 | t.Errorf("capacity $ref = %q", ref) | 175 | t.Errorf("capacity $ref = %q", ref) |
| 168 | } | 176 | } |
| 177 | |||
| 178 | // Pointer-to-struct field → anyOf [$ref, {type: null}]. | ||
| 179 | anyOf, ok := dig(t, props, "metrics", "anyOf").([]any) | ||
| 180 | if !ok || len(anyOf) != 2 { | ||
| 181 | t.Fatalf("metrics anyOf = %v", dig(t, props, "metrics")) | ||
| 182 | } | ||
| 183 | if ref, _ := anyOf[0].(map[string]any)["$ref"].(string); !strings.HasSuffix(ref, "Metrics") { | ||
| 184 | t.Errorf("metrics anyOf[0] = %v, want $ref ...Metrics", anyOf[0]) | ||
| 185 | } | ||
| 186 | if typ, _ := anyOf[1].(map[string]any)["type"].(string); typ != "null" { | ||
| 187 | t.Errorf("metrics anyOf[1] = %v, want {type: null}", anyOf[1]) | ||
| 188 | } | ||
| 189 | |||
| 190 | // Pointer-to-time field → type ["string","null"] with date-time format. | ||
| 191 | lastSeen := dig(t, props, "last_seen").(map[string]any) | ||
| 192 | typJSON := string(mustJSON(t, lastSeen["type"])) | ||
| 193 | if typJSON != `["string","null"]` { | ||
| 194 | t.Errorf("last_seen type = %s, want [\"string\",\"null\"]", typJSON) | ||
| 195 | } | ||
| 196 | if lastSeen["format"] != "date-time" { | ||
| 197 | t.Errorf("last_seen format = %v, want date-time", lastSeen["format"]) | ||
| 198 | } | ||
| 169 | } | 199 | } |
| 170 | 200 | ||
| 171 | func TestSSEAndWSResponses(t *testing.T) { | 201 | func TestSSEAndWSResponses(t *testing.T) { |
internal/server/api/testdata/audit-event.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -1,8 +0,0 @@ | |||
| 1 | { | ||
| 2 | "at": "2026-07-27T12:02:00Z", | ||
| 3 | "action": "host.enroll", | ||
| 4 | "detail": { | ||
| 5 | "host_id": "h-1234", | ||
| 6 | "remote": "203.0.113.7" | ||
| 7 | } | ||
| 8 | } | ||
internal/server/api/testdata/audit.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,9 @@ | |||
| 1 | [ | ||
| 2 | { | ||
| 3 | "at": "2026-07-27T12:02:00Z", | ||
| 4 | "action": "vm.create", | ||
| 5 | "detail": { | ||
| 6 | "vm_id": "v-1" | ||
| 7 | } | ||
| 8 | } | ||
| 9 | ] | ||
internal/server/api/testdata/enroll-token-response.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -1,4 +1,4 @@ | |||
| 1 | { | 1 | { |
| 2 | "join": "eitri-join-blob-base64url", | 2 | "join": "eyJqb2luIjoiYmxvYiJ9", |
| 3 | "token": "tok-secret-01" | 3 | "token": "tok-secret-01" |
| 4 | } | 4 | } |
internal/server/api/testdata/host.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -8,10 +8,10 @@ | |||
| 8 | "status": "active", | 8 | "status": "active", |
| 9 | "enrolled_at": "2026-07-27T12:00:00Z", | 9 | "enrolled_at": "2026-07-27T12:00:00Z", |
| 10 | "online": true, | 10 | "online": true, |
| 11 | "last_seen": null, | 11 | "last_seen": "2026-07-27T12:00:30Z", |
| 12 | "seconds_since_last_seen": null, | 12 | "seconds_since_last_seen": 42, |
| 13 | "stale": false, | 13 | "stale": true, |
| 14 | "sessions": 0, | 14 | "sessions": 3, |
| 15 | "capacity": { | 15 | "capacity": { |
| 16 | "vcpus": 16, | 16 | "vcpus": 16, |
| 17 | "mem_mb": 32768, | 17 | "mem_mb": 32768, |
internal/server/api/testdata/snapshot.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -10,10 +10,10 @@ | |||
| 10 | "status": "active", | 10 | "status": "active", |
| 11 | "enrolled_at": "2026-07-27T12:00:00Z", | 11 | "enrolled_at": "2026-07-27T12:00:00Z", |
| 12 | "online": true, | 12 | "online": true, |
| 13 | "last_seen": null, | 13 | "last_seen": "2026-07-27T12:00:30Z", |
| 14 | "seconds_since_last_seen": null, | 14 | "seconds_since_last_seen": 42, |
| 15 | "stale": false, | 15 | "stale": true, |
| 16 | "sessions": 0, | 16 | "sessions": 3, |
| 17 | "capacity": { | 17 | "capacity": { |
| 18 | "vcpus": 16, | 18 | "vcpus": 16, |
| 19 | "mem_mb": 32768, | 19 | "mem_mb": 32768, |
internal/server/api/testdata/stream-ticket-response.golden.json
| Old | New | ||
|---|---|---|---|
| @@ -1,3 +1,3 @@ | |||
| 1 | { | 1 | { |
| 2 | "ticket": "ticket-opaque-01" | 2 | "ticket": "tkt-onetime-01" |
| 3 | } | 3 | } |
internal/server/api/types/types.go
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,12 @@ | |||
| 2 | // response JSON shape the API speaks, and nothing else. It is a leaf — it | 2 | // response JSON shape the API speaks, and nothing else. It is a leaf — it |
| 3 | // imports only the standard library — so the contract can be consumed by the | 3 | // imports only the standard library — so the contract can be consumed by the |
| 4 | // spec generator, the shared client, and the handlers without dragging in | 4 | // spec generator, the shared client, and the handlers without dragging in |
| 5 | // server internals. | 5 | // server internals. Enforced by an arch fitness test. |
| 6 | // | ||
| 7 | // One deliberate mirror exists outside this package: | ||
| 8 | // internal/agent/enrollclient types the enroll exchange itself, because the | ||
| 9 | // agent plane must not import server packages. docs/openapi.json (generated | ||
| 10 | // from these types) is the cross-check that the mirror stays honest. | ||
| 6 | package types | 11 | package types |
| 7 | 12 | ||
| 8 | import ( | 13 | import ( |
| @@ -120,6 +125,25 @@ type StateSnapshot struct { | |||
| 120 | LatestVersion string `json:"latest_version"` // "" until the manifest is known | 125 | LatestVersion string `json:"latest_version"` // "" until the manifest is known |
| 121 | } | 126 | } |
| 122 | 127 | ||
| 128 | // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit | ||
| 129 | // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is | ||
| 130 | // always a marshaled object). | ||
| 131 | type AuditEvent struct { | ||
| 132 | At time.Time `json:"at"` | ||
| 133 | Action string `json:"action"` | ||
| 134 | Detail json.RawMessage `json:"detail"` | ||
| 135 | } | ||
| 136 | |||
| 137 | // RevokedCert is the wire form of one revoked SSH cert, served by | ||
| 138 | // GET /api/v1/ssh-certs/revoked. Serial is a STRING, not a JSON number: a | ||
| 139 | // uint64 serial routinely exceeds 2^53 and would lose precision in a | ||
| 140 | // JavaScript client that parsed it as a double. | ||
| 141 | type RevokedCert struct { | ||
| 142 | Serial string `json:"serial"` | ||
| 143 | RevokedAt time.Time `json:"revoked_at"` | ||
| 144 | Reason string `json:"reason"` | ||
| 145 | } | ||
| 146 | |||
| 123 | // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an | 147 | // EnrollRequest is the POST /api/v1/enroll body: an agent redeeming an |
| 124 | // enrollment token to join the fleet. | 148 | // enrollment token to join the fleet. |
| 125 | type EnrollRequest struct { | 149 | type EnrollRequest struct { |
| @@ -153,6 +177,26 @@ type PatchVMRequest struct { | |||
| 153 | PowerState string `json:"power_state"` | 177 | PowerState string `json:"power_state"` |
| 154 | } | 178 | } |
| 155 | 179 | ||
| 180 | // UserCARequest is the body of both POST and DELETE | ||
| 181 | // /api/v1/tenants/{tenant}/user-cas: a BYO user-CA public key (authorized_keys | ||
| 182 | // line) to register or remove, with an optional label on upload. | ||
| 183 | type UserCARequest struct { | ||
| 184 | PublicKey string `json:"public_key"` | ||
| 185 | Label string `json:"label"` | ||
| 186 | } | ||
| 187 | |||
| 188 | // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts | ||
| 189 | // EITHER a raw serial OR a full cert authorized-key line (from which the serial | ||
| 190 | // is extracted) — the by-line form is the ergonomic one (paste the cert you | ||
| 191 | // minted), the by-serial form is for programmatic callers. Serial is a pointer | ||
| 192 | // so an absent field is distinguishable from an explicit 0. Reason is optional | ||
| 193 | // audit metadata. | ||
| 194 | type RevokeSSHCertRequest struct { | ||
| 195 | Serial *uint64 `json:"serial"` | ||
| 196 | Certificate string `json:"certificate"` | ||
| 197 | Reason string `json:"reason"` | ||
| 198 | } | ||
| 199 | |||
| 156 | // The response shapes below replace handlers' inline map[string]string | 200 | // The response shapes below replace handlers' inline map[string]string |
| 157 | // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose: | 201 | // literals. Their fields are ordered ALPHABETICALLY BY JSON KEY on purpose: |
| 158 | // encoding/json marshals map keys sorted, so keeping struct fields in that | 202 | // encoding/json marshals map keys sorted, so keeping struct fields in that |
| @@ -200,42 +244,3 @@ type UserCA struct { | |||
| 200 | Label string `json:"label"` | 244 | Label string `json:"label"` |
| 201 | PubKey string `json:"pubkey"` | 245 | PubKey string `json:"pubkey"` |
| 202 | } | 246 | } |
| 203 | |||
| 204 | // AuditEvent is the wire shape of one audit row, served by GET /api/v1/audit | ||
| 205 | // and GET /api/v1/vms/{id}/events; detail is embedded as raw JSON (it is | ||
| 206 | // always a marshaled object). | ||
| 207 | type AuditEvent struct { | ||
| 208 | At time.Time `json:"at"` | ||
| 209 | Action string `json:"action"` | ||
| 210 | Detail json.RawMessage `json:"detail"` | ||
| 211 | } | ||
| 212 | |||
| 213 | // RevokedCert is the wire form of one revoked SSH cert, served by | ||
| 214 | // GET /api/v1/ssh-certs/revoked. Serial is a STRING, not a JSON number: a | ||
| 215 | // uint64 serial routinely exceeds 2^53 and would lose precision in a | ||
| 216 | // JavaScript client that parsed it as a double. | ||
| 217 | type RevokedCert struct { | ||
| 218 | Serial string `json:"serial"` | ||
| 219 | RevokedAt time.Time `json:"revoked_at"` | ||
| 220 | Reason string `json:"reason"` | ||
| 221 | } | ||
| 222 | |||
| 223 | // UserCARequest is the body of both POST and DELETE | ||
| 224 | // /api/v1/tenants/{tenant}/user-cas: a BYO user-CA public key (authorized_keys | ||
| 225 | // line) to register or remove, with an optional label on upload. | ||
| 226 | type UserCARequest struct { | ||
| 227 | PublicKey string `json:"public_key"` | ||
| 228 | Label string `json:"label"` | ||
| 229 | } | ||
| 230 | |||
| 231 | // RevokeSSHCertRequest is the POST /api/v1/ssh-certs/revoke body. It accepts | ||
| 232 | // EITHER a raw serial OR a full cert authorized-key line (from which the serial | ||
| 233 | // is extracted) — the by-line form is the ergonomic one (paste the cert you | ||
| 234 | // minted), the by-serial form is for programmatic callers. Serial is a pointer | ||
| 235 | // so an absent field is distinguishable from an explicit 0. Reason is optional | ||
| 236 | // audit metadata. | ||
| 237 | type RevokeSSHCertRequest struct { | ||
| 238 | Serial *uint64 `json:"serial"` | ||
| 239 | Certificate string `json:"certificate"` | ||
| 240 | Reason string `json:"reason"` | ||
| 241 | } | ||
internal/server/api/wire_golden_test.go
| Old | New | ||
|---|---|---|---|
| @@ -48,6 +48,8 @@ func goldenCheck(t *testing.T, name string, v any) { | |||
| 48 | // dropped field, renamed tag, or swapped tag shows up as a byte diff. | 48 | // dropped field, renamed tag, or swapped tag shows up as a byte diff. |
| 49 | func TestWireGolden(t *testing.T) { | 49 | func TestWireGolden(t *testing.T) { |
| 50 | base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) | 50 | base := time.Date(2026, 7, 27, 12, 0, 0, 0, time.UTC) |
| 51 | lastSeen := base.Add(30 * time.Second) | ||
| 52 | sinceLastSeen := int64(42) | ||
| 51 | 53 | ||
| 52 | host := types.Host{ | 54 | host := types.Host{ |
| 53 | ID: "h-1234", | 55 | ID: "h-1234", |
| @@ -59,6 +61,10 @@ func TestWireGolden(t *testing.T) { | |||
| 59 | Status: "active", | 61 | Status: "active", |
| 60 | EnrolledAt: base, | 62 | EnrolledAt: base, |
| 61 | Online: true, | 63 | Online: true, |
| 64 | LastSeen: &lastSeen, | ||
| 65 | SecondsSinceLastSeen: &sinceLastSeen, | ||
| 66 | Stale: true, | ||
| 67 | Sessions: 3, | ||
| 62 | Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512}, | 68 | Capacity: types.Capacity{VCPUs: 16, MemMB: 32768, DiskGB: 512}, |
| 63 | Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100}, | 69 | Allocated: types.Capacity{VCPUs: 4, MemMB: 8192, DiskGB: 100}, |
| 64 | AgentVersion: "v0.0.1-agent", | 70 | AgentVersion: "v0.0.1-agent", |
| @@ -111,6 +117,18 @@ func TestWireGolden(t *testing.T) { | |||
| 111 | LatestVersion: "v0.0.2-test", | 117 | LatestVersion: "v0.0.2-test", |
| 112 | }) | 118 | }) |
| 113 | 119 | ||
| 120 | goldenCheck(t, "audit", []types.AuditEvent{{ | ||
| 121 | At: base.Add(2 * time.Minute), | ||
| 122 | Action: "vm.create", | ||
| 123 | Detail: json.RawMessage(`{"vm_id":"v-1"}`), | ||
| 124 | }}) | ||
| 125 | |||
| 126 | goldenCheck(t, "revoked-cert", []types.RevokedCert{{ | ||
| 127 | Serial: "18446744073709551615", | ||
| 128 | RevokedAt: base.Add(3 * time.Minute), | ||
| 129 | Reason: "key compromised", | ||
| 130 | }}) | ||
| 131 | |||
| 114 | goldenCheck(t, "enroll-request", types.EnrollRequest{ | 132 | goldenCheck(t, "enroll-request", types.EnrollRequest{ |
| 115 | Token: "tok-secret-01", | 133 | Token: "tok-secret-01", |
| 116 | Name: "host-nine", | 134 | Name: "host-nine", |
| @@ -160,7 +178,7 @@ func TestWireGolden(t *testing.T) { | |||
| 160 | }) | 178 | }) |
| 161 | 179 | ||
| 162 | goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{ | 180 | goldenCheck(t, "enroll-token-response", types.EnrollTokenResponse{ |
| 163 | Join: "eitri-join-blob-base64url", | 181 | Join: "eyJqb2luIjoiYmxvYiJ9", |
| 164 | Token: "tok-secret-01", | 182 | Token: "tok-secret-01", |
| 165 | }) | 183 | }) |
| 166 | 184 | ||
| @@ -174,7 +192,7 @@ func TestWireGolden(t *testing.T) { | |||
| 174 | }) | 192 | }) |
| 175 | 193 | ||
| 176 | goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ | 194 | goldenCheck(t, "stream-ticket-response", types.StreamTicketResponse{ |
| 177 | Ticket: "ticket-opaque-01", | 195 | Ticket: "tkt-onetime-01", |
| 178 | }) | 196 | }) |
| 179 | 197 | ||
| 180 | goldenCheck(t, "user-ca-upload-response", types.UserCAUploadResponse{ | 198 | goldenCheck(t, "user-ca-upload-response", types.UserCAUploadResponse{ |
| @@ -186,16 +204,4 @@ func TestWireGolden(t *testing.T) { | |||
| 186 | Label: "team-alpha-ca", | 204 | Label: "team-alpha-ca", |
| 187 | PubKey: "ssh-ed25519 AAAAC3Nza ca-comment", | 205 | PubKey: "ssh-ed25519 AAAAC3Nza ca-comment", |
| 188 | }}) | 206 | }}) |
| 189 | |||
| 190 | goldenCheck(t, "audit-event", types.AuditEvent{ | ||
| 191 | At: base.Add(2 * time.Minute), | ||
| 192 | Action: "host.enroll", | ||
| 193 | Detail: json.RawMessage(`{"host_id":"h-1234","remote":"203.0.113.7"}`), | ||
| 194 | }) | ||
| 195 | |||
| 196 | goldenCheck(t, "revoked-cert", []types.RevokedCert{{ | ||
| 197 | Serial: "18446744073709551615", | ||
| 198 | RevokedAt: base.Add(3 * time.Minute), | ||
| 199 | Reason: "key compromised", | ||
| 200 | }}) | ||
| 201 | } | 207 | } |
internal/shape/build.go
| Old | New | ||
|---|---|---|---|
| @@ -24,7 +24,7 @@ func Build(raw []rawPackage) Model { | |||
| 24 | sort.Strings(imps) | 24 | sort.Strings(imps) |
| 25 | pkgs = append(pkgs, Package{ | 25 | pkgs = append(pkgs, Package{ |
| 26 | ImportPath: rel, | 26 | ImportPath: rel, |
| 27 | Plane: classify(rel), | 27 | Plane: Classify(rel), |
| 28 | Synopsis: synopsis(r.Doc), | 28 | Synopsis: synopsis(r.Doc), |
| 29 | Imports: imps, | 29 | Imports: imps, |
| 30 | }) | 30 | }) |
internal/shape/classify.go
| Old | New | ||
|---|---|---|---|
| @@ -52,3 +52,8 @@ func classify(rel string) Plane { | |||
| 52 | return PlaneUnclassified | 52 | return PlaneUnclassified |
| 53 | } | 53 | } |
| 54 | } | 54 | } |
| 55 | |||
| 56 | // Classify reports the plane for a module-relative import path; exported so | ||
| 57 | // the architecture fitness tests can drive plane-wide rules from the same | ||
| 58 | // source of truth as the diagram. | ||
| 59 | func Classify(rel string) Plane { return classify(rel) } | ||
scripts/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -25,8 +25,8 @@ declare -A FLOOR=( | |||
| 25 | [internal/agent/cloudhv]=40 | 25 | [internal/agent/cloudhv]=40 |
| 26 | [internal/agent/syncclient]=74 | 26 | [internal/agent/syncclient]=74 |
| 27 | [internal/server/api]=73 | 27 | [internal/server/api]=73 |
| 28 | [internal/server/api/client]=73 | 28 | [internal/server/api/client]=89 |
| 29 | [internal/server/api/spec]=81 | 29 | [internal/server/api/spec]=91 |
| 30 | [internal/server/store]=73 | 30 | [internal/server/store]=73 |
| 31 | [internal/server/registry]=95 | 31 | [internal/server/registry]=95 |
| 32 | [internal/server/release]=90 | 32 | [internal/server/release]=90 |
| @@ -34,7 +34,7 @@ declare -A FLOOR=( | |||
| 34 | [internal/agent/bootstrap]=70 | 34 | [internal/agent/bootstrap]=70 |
| 35 | [internal/server/hosttoken]=95 | 35 | [internal/server/hosttoken]=95 |
| 36 | [internal/server/hub]=90 | 36 | [internal/server/hub]=90 |
| 37 | [internal/server/syncsvc]=72 | 37 | [internal/server/syncsvc]=75 |
| 38 | [internal/server/web]=90 | 38 | [internal/server/web]=90 |
| 39 | [internal/transport]=77 | 39 | [internal/transport]=77 |
| 40 | [internal/shape]=88 | 40 | [internal/shape]=88 |
web/src/lib/api-types.ts
| Old | New | ||
|---|---|---|---|
| @@ -249,10 +249,13 @@ export interface paths { | |||
| 249 | get?: never; | 249 | get?: never; |
| 250 | put?: never; | 250 | put?: never; |
| 251 | post?: never; | 251 | post?: never; |
| 252 | /** Decommission a host: tombstone its VMs and drain gracefully (202). */ | 252 | /** Decommission a host: tombstone its VMs and drain gracefully (202). With ?force, purge and remove immediately, returning 200. */ |
| 253 | delete: { | 253 | delete: { |
| 254 | parameters: { | 254 | parameters: { |
| 255 | query?: never; | 255 | query?: { |
| 256 | /** @description purge VM rows and remove the host immediately (dead hardware escape hatch) */ | ||
| 257 | force?: string; | ||
| 258 | }; | ||
| 256 | header?: never; | 259 | header?: never; |
| 257 | path: { | 260 | path: { |
| 258 | id: string; | 261 | id: string; |