a73x

13b9c2e5

feat: architecture governance and the explorable shape diagram

a73x   2026-07-25 09:05

Commit message
feat: architecture governance and the explorable shape diagram

.golangci.yml
Old New
@@ -0,0 +1,135 @@
1 # golangci-lint v2 configuration for Eitri.
2 #
3 # Two tiers (see docs/architecture.md and the CI workflow):
4 # - correctness + boundary linters: BLOCK merge (run via `make lint`)
5 # - complexity/style linters: WARN only at first, promoted to block
6 # one at a time as the baseline stays clean
7 #
8 # depguard here is the fast, in-editor half of the boundary enforcement; the
9 # transitive-closure half lives in internal/arch/arch_test.go (R1–R6).
10 version: "2"
11
12 run:
13 timeout: 5m
14 # Lint the integration tiers too, not just the default build.
15 build-tags:
16 - smoke
17 - sandbox
18
19 linters:
20 default: none
21 # BLOCK TIER — these are clean today and `make lint` fails CI on any finding.
22 # The complexity/style WARN tier (errcheck, revive, gocyclo, funlen, gocritic,
23 # misspell, unconvert, nakedret) is run informationally by `make lint-extra`
24 # (exit code 0). Their settings live below so they apply when enabled via CLI.
25 # Promote a warn linter into this list once its baseline is clean.
26 enable:
27 # --- correctness ---
28 - govet
29 - staticcheck # also covers the old gosimple + stylecheck
30 - ineffassign
31 - unused
32 - bodyclose # http bodies in cloudhv.socketClient / imagecache (prod only)
33 - rowserrcheck # database/sql in server/store
34 - sqlclosecheck # *sql.Rows in store.scanVM / ListVMs
35 # --- context discipline (matches the ctx-first-arg convention) ---
36 - contextcheck
37 - containedctx # forbid context.Context stored in structs; Engine takes ctx per-call
38 # --- decoupling / boundaries ---
39 - depguard # encodes R1/R2/R4 as import bans (below)
40 - ireturn # nudge toward concrete returns; allow at the documented seams
41
42 settings:
43 staticcheck:
44 # Quietly drop the purely-stylistic quickfix nags (redundant type in decl,
45 # embedded-field selector) — keep the correctness checks blocking.
46 checks:
47 - all
48 - -QF1008
49 - -QF1011
50 gocyclo:
51 min-complexity: 20 # reconcile.Step is the one legit outlier; excluded below
52 funlen:
53 lines: 90
54 statements: 60
55 ireturn:
56 allow:
57 - error
58 - empty
59 - stdlib
60 # documented consumer-side seams that intentionally return interfaces:
61 - github.com/a73x/eitri/internal/agent/overlay.Overlay
62 depguard:
63 rules:
64 # R1 cross-plane bans apply to production code only; integration tests
65 # (e.g. agent/syncclient/client_test.go) legitimately wire up both planes.
66 # The transitive-closure check in internal/arch also covers production only.
67 server-no-agent: # R1: control plane must not import data plane
68 files:
69 - "**/internal/server/**"
70 - "!**/*_test.go"
71 deny:
72 - pkg: github.com/a73x/eitri/internal/agent
73 desc: control plane (server) must not import data plane (agent)
74 server-no-exec: # R2: the server is a control plane and never shells out
75 files:
76 - "**/internal/server/**"
77 deny:
78 - pkg: os/exec
79 desc: the server expresses desired state and must never shell out
80 agent-no-server: # R1: data plane must not import control plane
81 files:
82 - "**/internal/agent/**"
83 - "!**/*_test.go"
84 deny:
85 - pkg: github.com/a73x/eitri/internal/server
86 desc: data plane (agent) must not import control plane (server)
87 domain-no-transport: # R4: pure domain stays serialization-agnostic
88 files:
89 - "**/internal/agent/state/**"
90 - "**/internal/agent/seed/**"
91 - "**/internal/agent/ipalloc/**"
92 - "**/internal/server/registry/**"
93 deny:
94 - pkg: net/http
95 desc: domain logic must not depend on the HTTP stack
96 - pkg: github.com/quic-go/quic-go
97 desc: domain logic must not depend on QUIC
98 - pkg: github.com/a73x/eitri/internal/transport
99 desc: domain logic must not depend on the transport layer
100
101 exclusions:
102 # Auto-exclude files carrying a "DO NOT EDIT" generated header (the .pb.go).
103 generated: lax
104 rules:
105 # reconcile.Step is an intentionally linear, well-commented state machine.
106 - path: internal/agent/reconcile/reconcile.go
107 linters:
108 - funlen
109 - gocyclo
110 source: "func \\(e \\*Engine\\) Step"
111 # Generated protobuf — belt-and-suspenders alongside `generated: lax`.
112 - path: 'internal/pb/.*\.pb\.go'
113 linters:
114 - govet
115 - gocritic
116 - revive
117 - unused
118 - funlen
119 - gocyclo
120 - staticcheck
121 # Tests: fakes and table tests run long; ctx-in-struct is fine in fixtures;
122 # an unclosed httptest response body in a test leaks nothing meaningful.
123 - path: _test\.go
124 linters:
125 - funlen
126 - containedctx
127 - bodyclose
128 # contextcheck adds value in the core libraries; the CLI entrypoints and
129 # the integration harness use context.Background in shutdown paths by design.
130 - path: (^|/)cmd/
131 linters:
132 - contextcheck
133 - path: (^|/)internal/integration/
134 linters:
135 - contextcheck
Makefile
Old New
@@ -1,7 +1,17 @@
1 BIN := bin 1 BIN := bin
2 WEB_DIST := internal/server/web/dist 2 WEB_DIST := internal/server/web/dist
3 3
4 .PHONY: build web test vet proto api api-check smoke smoke-go devstack sandbox clean 4 # Pinned so local and CI lint identically. Bump deliberately.
5 GOLANGCI_VERSION := v2.12.2
6 GOLANGCI := $(shell go env GOPATH)/bin/golangci-lint
7 # Pinned dead-code analyzer (golang.org/x/tools/cmd/deadcode). Bump deliberately.
8 DEADCODE_VERSION := v0.48.0
9 # Style/complexity linters run informationally (exit 0); promote into
10 # .golangci.yml's enable list once a linter's baseline is clean.
11 LINT_WARN := errcheck,revive,gocyclo,funlen,gocritic,misspell,unconvert,nakedret
12
13 .PHONY: build build-go web test vet proto smoke smoke-go devstack sandbox clean \
14 lint lint-extra arch cover tidy-check proto-check shape shape-check api api-check ci deadcode
5 15
6 # Build the SvelteKit SPA and stage it into the Go embed dir. Requires Node. 16 # Build the SvelteKit SPA and stage it into the Go embed dir. Requires Node.
7 # `go build` works without this (the server serves a "UI not built" notice until 17 # `go build` works without this (the server serves a "UI not built" notice until
@@ -66,5 +76,85 @@ devstack: build
66 sandbox: 76 sandbox:
67 go test -tags=sandbox -timeout=40m -count=1 ./internal/integration/sandbox -run TestSandbox -v 77 go test -tags=sandbox -timeout=40m -count=1 ./internal/integration/sandbox -run TestSandbox -v
68 78
79 # --- quality gates -----------------------------------------------------------
80
81 # Architecture fitness functions (R1–R6). -count=1 is mandatory: these tests
82 # shell out to `go list`, so Go's test cache cannot see edges changing elsewhere.
83 arch:
84 go test -count=1 ./internal/arch/
85
86 $(GOLANGCI):
87 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
88
89 # Block tier: fails on any finding (boundaries + correctness). See .golangci.yml.
90 lint: $(GOLANGCI)
91 $(GOLANGCI) run ./...
92
93 # Warn tier: complexity/style, reported but never fails the build.
94 lint-extra: $(GOLANGCI)
95 $(GOLANGCI) run --default=none --enable=$(LINT_WARN) --issues-exit-code=0 ./...
96
97 # Per-package coverage ratchet (see scripts/coverage.sh).
98 cover:
99 ./scripts/coverage.sh
100
101 # Dependency hygiene: `go mod tidy` must produce no diff.
102 tidy-check:
103 go mod tidy
104 git diff --exit-code go.mod go.sum
105
106 # Generated protobuf must match proto/eitri/v1/sync.proto. The `-I` ignores the
107 # protoc/protoc-gen-go version-stamp comment lines so the gate tracks real code
108 # drift rather than the exact toolchain patch version. Skips (does not fail)
109 # when protoc is unavailable so `make ci` still runs on protoc-less machines;
110 # CI installs protoc, so the gate is enforced there.
111 proto-check:
112 @if ! command -v protoc >/dev/null 2>&1; then \
113 echo "proto-check: protoc not installed — SKIPPING (enforced in CI)"; \
114 else \
115 $(MAKE) proto && git diff --exit-code -I '^//[[:space:]]+protoc' internal/pb || \
116 { echo "proto-check: internal/pb is stale — run 'make proto'"; exit 1; }; \
117 fi
118
119 # Regenerate the explorable architecture-shape diagram (docs/shape.{json,html}).
120 shape:
121 go run ./cmd/eitri-shape
122
123 # Merge gate: the committed diagram must match the current package graph.
124 # Mirrors proto-check — regenerate, then fail on any diff.
125 shape-check:
126 go run ./cmd/eitri-shape
127 git diff --exit-code docs/shape.json docs/shape.html || \
128 { echo "shape-check: docs/shape.{json,html} are stale — run 'make shape'"; exit 1; }
129
130 # Whole-program dead-code gate: fails on any function unreachable from a real
131 # entrypoint — every main() in cmd/. Rooting at the binaries (NOT -test) is what
132 # catches production code kept alive only by its own tests; the fix is to remove
133 # it, wire it into a real path, or move it into a _test.go. The smoke/sandbox tags
134 # compile the tag-gated code so it is analysed too. Three sanctioned exceptions,
135 # all production code that only a CROSS-package test can reach (so none can be
136 # a _test.go): internal/integration (the e2e/harness tree), reconcile.Engine.Stop
137 # (terminal teardown that must not run in production — it would report every VM as
138 # vanished — used only by an integration test's cleanup), and store.Store.Close /
139 # store.Store.Epoch (reachable only via the store's tests until the serial console
140 # stream lands and flows the store through a Close()-bearing interface).
141 deadcode:
142 @out=$$(go run golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION) -tags=smoke,sandbox ./... \
143 | { grep -vE '^internal/integration/|unreachable func: Engine\.Stop$$|unreachable func: Store\.Close$$|unreachable func: Store\.Epoch$$' || true; }); \
144 if [ -n "$$out" ]; then \
145 echo "deadcode: unreachable from any cmd/ entrypoint (remove it, wire it in, or move it to a _test.go):"; \
146 echo "$$out"; exit 1; \
147 fi
148
149 # The merge gate. Mirrors the required checks in CI. `test` is the authoritative
150 # race-detector run; `cover` re-runs without -race to enforce the ratchet; `arch`
151 # re-runs the fitness tests with -count=1 (the race run may serve them cached).
152 ci: vet build-go arch lint test cover tidy-check proto-check api-check shape-check deadcode
153
154 # Compile every Go package (no Node/web build needed — the embed dir ships a
155 # placeholder, so the server builds and serves a "UI not built" notice).
156 build-go:
157 go build ./...
158
69 clean: 159 clean:
70 rm -rf $(BIN) 160 rm -rf $(BIN)
cmd/eitri-shape/main.go
Old New
@@ -0,0 +1,29 @@
1 // eitri-shape regenerates the explorable architecture-shape diagram from the
2 // real package graph (`go list`). It writes two committed artifacts at the repo
3 // root: docs/shape.json (the authoritative, diff-reviewable model) and
4 // docs/shape.html (a self-contained viewer with the JSON inlined verbatim).
5 //
6 // go run ./cmd/eitri-shape // or: make shape
7 //
8 // `make shape-check` regenerates and fails CI if either artifact is stale.
9 package main
10
11 import (
12 "log"
13 "os"
14
15 "github.com/a73x/eitri/internal/shape"
16 )
17
18 func main() {
19 jsonBytes, err := shape.Generate()
20 if err != nil {
21 log.Fatalf("eitri-shape: %v", err)
22 }
23 if err := os.WriteFile("docs/shape.json", jsonBytes, 0o644); err != nil {
24 log.Fatalf("eitri-shape: write docs/shape.json: %v", err)
25 }
26 if err := os.WriteFile("docs/shape.html", []byte(shape.RenderHTML(jsonBytes)), 0o644); err != nil {
27 log.Fatalf("eitri-shape: write docs/shape.html: %v", err)
28 }
29 }
docs/architecture.md
Old New
@@ -0,0 +1,114 @@
1 # Eitri architecture & invariants
2
3 This document records the architectural invariants that keep Eitri's control
4 plane and data plane decoupled, and the **executable** governance that enforces
5 them. Every invariant below is backed by a test or lint rule — if you violate
6 one, `make ci` fails. The intent is that the architecture cannot silently drift.
7
8 ## The two planes
9
10 Eitri is split into two independently deployable halves that run on different
11 hosts and share only a wire contract:
12
13 | Plane | Packages | Role |
14 |-------|----------|------|
15 | **Control plane** (server) | `internal/server/*`, `cmd/eitri-server` | Holds *desired* state, tracks *actual* state, exposes the API + SSE UI. Never touches a VM. |
16 | **Data plane** (agent) | `internal/agent/*`, `cmd/eitri-agent` | Owns the entire VM lifecycle: disks, networking, cloud-hypervisor processes. |
17 | **Wire contract** | `internal/pb`, `internal/transport` | The only code shared across the boundary: protobuf messages + QUIC framing/TLS. |
18
19 The server expresses intent as a `pb.DesiredStateSnapshot` (server → agent); the
20 agent reports back a `pb.ActualStateReport` (agent → server). The server's only
21 "actuation" is writing desired state to its store and poking the SSE hub. All
22 side effects on real infrastructure live in the agent.
23
24 ## Invariants
25
26 | # | Invariant | Enforced by |
27 |---|-----------|-------------|
28 | **R1** | The control plane and the data plane never import each other (even transitively). | `internal/arch` `TestControlAndDataPlaneAreDisjoint` (production, transitive) + `depguard` `server-no-agent` / `agent-no-server` (non-test files). |
29 | **R2** | No `internal/server` package shells out — the server is pure control plane. | `internal/arch` `TestServerNeverShellsOut` + `depguard` `server-no-exec`. |
30 | **R3** | The wire contract (`pb`, `transport`) imports no other internal package, so a heavy dependency can't leak across the boundary into both binaries. | `internal/arch` `TestWireContractIsLeaf`. Behavior pinned by `transport` round-trip contract tests. |
31 | **R4** | Pure domain packages (`agent/state`, `agent/seed`, `agent/ipalloc`, `server/registry`) don't depend on the transport stack (HTTP/QUIC/`transport`). `server/store` may use `transport` (cert helpers) but not HTTP/QUIC. | `internal/arch` `TestDomainDoesNotImportTransportStack` + `depguard` `domain-no-transport`. |
32 | **R5** | The reconcile boundary interfaces (`Provisioner`, `NetEnv`, `Overlay`) stay consumer-owned and small; the IPAM seam (`NetEnv.AllocateIP`/`GuestNetwork`) is where a future central allocator plugs in. | Convention (below) + `ireturn` allow-list keeps the seams' interface returns honest. |
33 | **R6** | All external process execution in the data plane funnels through `agent/exec.Runner`. The sole exception is `agent/cloudhv`, which launches the long-lived cloud-hypervisor process directly. | `internal/arch` `TestOnlyCloudhvImportsOsExecInDataPlane`. |
34
35 > The `internal/arch` tests shell out to `go list`, so Go's test cache can't see
36 > edges changing elsewhere in the module. Always run them with `-count=1`
37 > (`make arch` and CI do).
38
39 ## Design conventions
40
41 These aren't all mechanically enforced, but they're how the code is shaped and
42 why the invariants hold:
43
44 1. **Consumer-side interfaces only.** An interface is declared by the package
45 that *uses* it, not the one that implements it. `reconcile` owns
46 `Provisioner` and `NetEnv`; `cloudhv`/`netenv` implement them. Keep them
47 minimal (`Provisioner` is 5 methods). This is what lets the cloud-hypervisor
48 backend and the IP allocator evolve without touching the reconcile loop.
49 2. **Dependency injection via struct + function fields, no DI framework.**
50 `reconcile.Engine` is the template: collaborators as interface fields
51 (`Prov`, `Net`), pure side effects as func fields (`Images`, `Seed`,
52 `BootID`, `Now`). Injected time (`Now func() time.Time`) is the sanctioned
53 way to make decision logic testable — don't call `time.Now()` directly in
54 reconcile/store decision paths.
55 3. **No mutable global state.** Constructors (`store.Open`, `hub.New`,
56 `syncsvc.New`) own all state. Immutable package vars (compiled regexps) are
57 fine.
58 4. **Mock only true external dependencies.** Tests use hand-written fakes for
59 the boundary interfaces (`Provisioner`, `NetEnv`, `Overlay`) and the
60 `exec.Runner`. The SQLite store is used for real in tests, not mocked. Don't
61 introduce a mocking framework — hand-written fakes keep tests honest about
62 real behavior.
63 5. **The server expresses intent, never actuates.** A new server feature that
64 wants something to happen to a VM adds it to desired state; the agent makes
65 it so.
66
67 ## The quality gate
68
69 `make ci` is the merge gate and runs locally identically to CI:
70
71 | Step | Target | Blocks merge? |
72 |------|--------|---------------|
73 | Compile all packages | `make build-go` | yes |
74 | `go vet` | `make vet` | yes |
75 | Architecture fitness tests (R1–R6) | `make arch` | yes |
76 | Block-tier lint (boundaries + correctness) | `make lint` | yes |
77 | Race-detector tests | `make test` | yes |
78 | Per-package coverage ratchet | `make cover` | yes |
79 | `go mod tidy` drift | `make tidy-check` | yes |
80 | Generated protobuf drift | `make proto-check` | yes |
81 | Architecture-shape diagram drift | `make shape-check` | yes |
82 | Complexity/style lint | `make lint-extra` | **no** (informational) |
83
84 Heavy integration tiers (`make smoke-go`, `make sandbox`) need KVM /
85 cloud-hypervisor / nested QEMU and are not part of the per-PR gate; run them on
86 privileged runners or locally.
87
88 ### Linting tiers
89
90 `.golangci.yml` enables only the **block tier** — linters that are clean today
91 and must stay clean (`govet`, `staticcheck`, `ineffassign`, `unused`,
92 `bodyclose`, `rowserrcheck`, `sqlclosecheck`, `contextcheck`, `containedctx`,
93 `depguard`, `ireturn`). The **warn tier** (`errcheck`, `revive`, `gocyclo`,
94 `funlen`, `gocritic`, `misspell`, `unconvert`, `nakedret`) runs via
95 `make lint-extra` with exit code 0. Promote a warn linter into the block list
96 once its baseline is clean.
97
98 ### Coverage ratchet
99
100 `scripts/coverage.sh` floors each package a few points below its current
101 coverage. CI fails if any package drops below its floor; raise the floor when
102 you raise coverage. Aspirational targets (not yet enforced): logic/domain
103 packages → 80%, host-touching effectful packages → 50%. Generated code
104 (`internal/pb`), thin `cmd/*` mains, and the tag-gated integration tiers are not
105 gated here.
106
107 ### The shape diagram
108
109 `make shape` regenerates `docs/shape.html` — a self-contained, explorable view
110 of the package graph (open it directly in a browser; no server needed). It is
111 generated from `go list`, so it cannot drift from the code; `make shape-check`
112 gates it. `docs/shape.json` is the authoritative model that produces it. New
113 top-level packages must be classified in `internal/shape/classify.go`, enforced
114 by `TestNoUnclassifiedPackagesInModule`.
docs/shape.html
Old New
@@ -0,0 +1,504 @@
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <title>eitri — architecture shape</title>
6 <style>
7 :root { font-family: system-ui, sans-serif; }
8 body { margin: 0; display: flex; height: 100vh; color: #1a1a1a; overflow: hidden; }
9 #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
10 header.top { padding: 12px 16px; border-bottom: 1px solid #eee; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
11 header.top h1 { font-size: 15px; margin: 0; }
12 header.top .mod { color: #888; font-size: 12px; font-family: ui-monospace, monospace; }
13 #legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; align-items: center; }
14 #legend span { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
15 #legend span.off { opacity: 0.4; text-decoration: line-through; }
16 #legend i { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
17 .btn { font: inherit; font-size: 12px; padding: 2px 9px; border: 1px solid #ccc; border-radius: 5px;
18 background: #fff; color: #333; cursor: pointer; }
19 .btn:hover { background: #f0f0f0; }
20 #reset[hidden] { display: none; }
21 #canvas { flex: 1; min-height: 0; }
22 svg { width: 100%; height: 100%; display: block; background: #fcfcfc; cursor: grab; }
23 .edge { stroke: #c8c8cf; stroke-width: 1; }
24 .edge.hot { stroke: #333; stroke-width: 1.6; }
25 .node { cursor: pointer; }
26 .node circle { stroke: #fff; stroke-width: 1.5; }
27 .node text { font-size: 9px; fill: #333; pointer-events: none; font-family: ui-monospace, monospace; }
28 .node.dim { opacity: 0.18; }
29 .edge.dim { opacity: 0.07; }
30 .node.sel circle { stroke: #111; stroke-width: 2.5; }
31 #panel { width: 320px; border-left: 1px solid #ddd; padding: 20px; overflow: auto; background: #fafafa; }
32 #panel h2 { font-size: 13px; margin: 16px 0 6px; color: #444; }
33 #panel h1 { font-size: 14px; margin: 0 0 8px; }
34 code { font-family: ui-monospace, monospace; font-size: 12px; }
35 .imports { margin: 0; padding-left: 18px; }
36 .imports li { font-size: 12px; }
37 .empty { color: #888; }
38 .hint { color: #999; font-size: 12px; }
39 </style>
40 </head>
41 <body>
42 <div id="main">
43 <header class="top">
44 <h1>eitri — architecture shape <span class="mod" id="modtag"></span></h1>
45 <div id="legend"></div>
46 <button id="reset" class="btn" hidden>reset filters</button>
47 </header>
48 <div id="canvas"></div>
49 </div>
50 <div id="panel"><p class="hint">Drag to pull the graph apart. Hover a node to trace its edges. Click for details.</p></div>
51 <script type="application/json" id="shape-data">
52 {
53 "module": "github.com/a73x/eitri",
54 "packages": [
55 {
56 "importPath": "cmd/eitri-agent",
57 "plane": "binaries",
58 "synopsis": "eitri-agent: BYO-hardware agent.",
59 "imports": [
60 "internal/agent/cloudhv",
61 "internal/agent/imagecache",
62 "internal/agent/netenv",
63 "internal/agent/overlay",
64 "internal/agent/reconcile",
65 "internal/agent/seed",
66 "internal/agent/state",
67 "internal/agent/syncclient"
68 ]
69 },
70 {
71 "importPath": "cmd/eitri-apispec",
72 "plane": "binaries",
73 "synopsis": "Command eitri-apispec regenerates docs/openapi.json from the api route table.",
74 "imports": [
75 "internal/server/api/spec"
76 ]
77 },
78 {
79 "importPath": "cmd/eitri-server",
80 "plane": "binaries",
81 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
82 "imports": [
83 "internal/server/api",
84 "internal/server/hub",
85 "internal/server/registry",
86 "internal/server/store",
87 "internal/server/syncsvc",
88 "internal/server/web",
89 "internal/transport"
90 ]
91 },
92 {
93 "importPath": "cmd/eitri-shape",
94 "plane": "binaries",
95 "synopsis": "eitri-shape regenerates the explorable architecture-shape diagram from the real package graph (`go list`).",
96 "imports": [
97 "internal/shape"
98 ]
99 },
100 {
101 "importPath": "internal/agent/cloudhv",
102 "plane": "data",
103 "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.",
104 "imports": [
105 "internal/agent/exec",
106 "internal/agent/state"
107 ]
108 },
109 {
110 "importPath": "internal/agent/exec",
111 "plane": "data",
112 "synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv, overlay).",
113 "imports": []
114 },
115 {
116 "importPath": "internal/agent/imagecache",
117 "plane": "data",
118 "synopsis": "Package imagecache downloads, verifies, and raw-converts base images.",
119 "imports": [
120 "internal/agent/exec"
121 ]
122 },
123 {
124 "importPath": "internal/agent/ipalloc",
125 "plane": "data",
126 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
127 "imports": []
128 },
129 {
130 "importPath": "internal/agent/netenv",
131 "plane": "data",
132 "synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.",
133 "imports": [
134 "internal/agent/exec",
135 "internal/agent/ipalloc"
136 ]
137 },
138 {
139 "importPath": "internal/agent/overlay",
140 "plane": "data",
141 "synopsis": "Package overlay abstracts how a host's bridge CIDR becomes reachable from the user's network.",
142 "imports": [
143 "internal/agent/exec"
144 ]
145 },
146 {
147 "importPath": "internal/agent/reconcile",
148 "plane": "data",
149 "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.",
150 "imports": [
151 "internal/agent/seed",
152 "internal/agent/state",
153 "internal/pb"
154 ]
155 },
156 {
157 "importPath": "internal/agent/seed",
158 "plane": "data",
159 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
160 "imports": []
161 },
162 {
163 "importPath": "internal/agent/state",
164 "plane": "data",
165 "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).",
166 "imports": []
167 },
168 {
169 "importPath": "internal/agent/syncclient",
170 "plane": "data",
171 "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.",
172 "imports": [
173 "internal/agent/reconcile",
174 "internal/agent/state",
175 "internal/pb",
176 "internal/transport"
177 ]
178 },
179 {
180 "importPath": "internal/arch",
181 "plane": "tooling",
182 "synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.",
183 "imports": []
184 },
185 {
186 "importPath": "internal/pb",
187 "plane": "wire",
188 "synopsis": "",
189 "imports": []
190 },
191 {
192 "importPath": "internal/server/api",
193 "plane": "control",
194 "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.",
195 "imports": [
196 "internal/server/api/types",
197 "internal/server/hosttoken",
198 "internal/server/hub",
199 "internal/server/registry",
200 "internal/server/store"
201 ]
202 },
203 {
204 "importPath": "internal/server/api/spec",
205 "plane": "control",
206 "synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.",
207 "imports": [
208 "internal/server/api",
209 "internal/server/api/types"
210 ]
211 },
212 {
213 "importPath": "internal/server/api/types",
214 "plane": "control",
215 "synopsis": "Package types is the server HTTP API's wire contract: every request and response JSON shape the API speaks, and nothing else.",
216 "imports": []
217 },
218 {
219 "importPath": "internal/server/hosttoken",
220 "plane": "control",
221 "synopsis": "Package hosttoken mints and verifies host credentials: \"\u003chost_id\u003e.\u003chex hmac-sha256\u003e\".",
222 "imports": []
223 },
224 {
225 "importPath": "internal/server/hub",
226 "plane": "control",
227 "synopsis": "Package hub wakes per-host QUIC streams when desired state changes.",
228 "imports": []
229 },
230 {
231 "importPath": "internal/server/registry",
232 "plane": "control",
233 "synopsis": "Package registry holds volatile actual state in memory.",
234 "imports": []
235 },
236 {
237 "importPath": "internal/server/store",
238 "plane": "control",
239 "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.",
240 "imports": [
241 "internal/transport"
242 ]
243 },
244 {
245 "importPath": "internal/server/syncsvc",
246 "plane": "control",
247 "synopsis": "Package syncsvc is the QUIC server end of the agent reconcile stream.",
248 "imports": [
249 "internal/pb",
250 "internal/server/hosttoken",
251 "internal/server/hub",
252 "internal/server/registry",
253 "internal/server/store",
254 "internal/transport"
255 ]
256 },
257 {
258 "importPath": "internal/server/web",
259 "plane": "control",
260 "synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).",
261 "imports": []
262 },
263 {
264 "importPath": "internal/shape",
265 "plane": "tooling",
266 "synopsis": "Package shape generates an explorable diagram of eitri's package graph from the real `go list` output, so the architecture view cannot silently drift from the code.",
267 "imports": []
268 },
269 {
270 "importPath": "internal/transport",
271 "plane": "wire",
272 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
273 "imports": []
274 }
275 ]
276 }
277
278 </script>
279 <script>
280 const PLANES = [
281 ["control", "control", "#4571c4"], ["data", "data", "#d04a4a"],
282 ["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"],
283 ["tooling", "tooling", "#7a7a7a"], ["unclassified", "unclassified", "#d4a017"],
284 ];
285 const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c]));
286 const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
287
288 const model = JSON.parse(document.getElementById("shape-data").textContent);
289 document.getElementById("modtag").textContent = model.module;
290
291 // --- filter state --------------------------------------------------------
292 // A node is in the simulation/render iff it is not individually hidden and its
293 // plane is not toggled off. Hidden nodes are dropped from forces and edges, so
294 // the layout genuinely re-flows around what remains.
295 const hiddenPlanes = new Set();
296 const visible = n => !n.hidden && !hiddenPlanes.has(n.plane);
297
298 // --- build node + edge sets ---------------------------------------------
299 const W = 1000, H = 700;
300 const nodes = model.packages.map((p, i) => ({
301 id: p.importPath, plane: p.plane, synopsis: p.synopsis, imports: p.imports || [],
302 // deterministic initial placement on a circle (no RNG → reproducible layout)
303 x: W / 2 + Math.cos(i / model.packages.length * 2 * Math.PI) * 250,
304 y: H / 2 + Math.sin(i / model.packages.length * 2 * Math.PI) * 250,
305 vx: 0, vy: 0, fixed: false, hidden: false,
306 }));
307 const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
308 const edges = [];
309 for (const n of nodes)
310 for (const imp of n.imports)
311 if (byId[imp]) edges.push({ s: n, t: byId[imp] });
312 const neighbors = new Map(nodes.map(n => [n.id, new Set()]));
313 for (const e of edges) { neighbors.get(e.s.id).add(e.t.id); neighbors.get(e.t.id).add(e.s.id); }
314
315 // --- SVG ------------------------------------------------------------------
316 const SVGNS = "http://www.w3.org/2000/svg";
317 const svg = document.createElementNS(SVGNS, "svg");
318 svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
319 svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
320 markerWidth="7" markerHeight="7" orient="auto-start-reverse">
321 <path d="M0,0 L10,5 L0,10 z" fill="#c8c8cf"/></marker></defs>`;
322 document.getElementById("canvas").appendChild(svg);
323
324 const edgeEls = edges.map(e => {
325 const l = document.createElementNS(SVGNS, "line");
326 l.setAttribute("class", "edge");
327 l.setAttribute("marker-end", "url(#arrow)");
328 svg.appendChild(l);
329 e.el = l;
330 return l;
331 });
332 const nodeEls = nodes.map(n => {
333 const g = document.createElementNS(SVGNS, "g");
334 g.setAttribute("class", "node");
335 const c = document.createElementNS(SVGNS, "circle");
336 c.setAttribute("r", 7);
337 c.setAttribute("fill", COLOR[n.plane] || "#999");
338 const t = document.createElementNS(SVGNS, "text");
339 t.setAttribute("x", 10); t.setAttribute("y", 3);
340 t.textContent = n.id.split("/").pop();
341 g.appendChild(c); g.appendChild(t);
342 svg.appendChild(g);
343 n.g = g;
344 g.addEventListener("mouseenter", () => highlight(n));
345 g.addEventListener("mouseleave", () => highlight(null));
346 g.addEventListener("mousedown", ev => startDrag(n, ev));
347 g.addEventListener("click", () => select(n));
348 return g;
349 });
350
351 function draw() {
352 for (const e of edges) {
353 if (!visible(e.s) || !visible(e.t)) { e.el.style.display = "none"; continue; }
354 e.el.style.display = "";
355 e.el.setAttribute("x1", e.s.x); e.el.setAttribute("y1", e.s.y);
356 e.el.setAttribute("x2", e.t.x); e.el.setAttribute("y2", e.t.y);
357 }
358 for (const n of nodes) {
359 n.g.style.display = visible(n) ? "" : "none";
360 n.g.setAttribute("transform", `translate(${n.x},${n.y})`);
361 }
362 }
363
364 // --- force simulation (velocity + alpha-decay, settles to rest) ----------
365 const REPULSION = 6000; // node-node push (charge)
366 const LINK_DIST = 140; // ideal edge length
367 const LINK_STR = 0.04; // edge spring stiffness
368 const CENTER = 0.015; // gravity toward centre (keeps graph on-screen)
369 const DECAY = 0.6; // velocity damping per tick
370 const ALPHA_DECAY = 0.02, ALPHA_MIN = 0.003;
371 let alpha = 1, running = false;
372
373 function tick() {
374 // node-node repulsion (all pairs; n=30 so O(n²) is trivial)
375 for (let i = 0; i < nodes.length; i++)
376 for (let j = i + 1; j < nodes.length; j++) {
377 const a = nodes[i], b = nodes[j];
378 if (!visible(a) || !visible(b)) continue;
379 let dx = a.x - b.x, dy = a.y - b.y;
380 const d2 = dx * dx + dy * dy + 1, d = Math.sqrt(d2);
381 const f = REPULSION / d2 * alpha;
382 const ux = dx / d * f, uy = dy / d * f;
383 a.vx += ux; a.vy += uy; b.vx -= ux; b.vy -= uy;
384 }
385 // edge springs toward LINK_DIST
386 for (const e of edges) {
387 if (!visible(e.s) || !visible(e.t)) continue;
388 let dx = e.t.x - e.s.x, dy = e.t.y - e.s.y;
389 const d = Math.hypot(dx, dy) || 0.01;
390 const f = (d - LINK_DIST) * LINK_STR * alpha;
391 const ux = dx / d * f, uy = dy / d * f;
392 e.s.vx += ux; e.s.vy += uy; e.t.vx -= ux; e.t.vy -= uy;
393 }
394 // gravity + integrate with damping
395 for (const n of nodes) {
396 if (!visible(n)) continue;
397 if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
398 n.vx += (W / 2 - n.x) * CENTER * alpha;
399 n.vy += (H / 2 - n.y) * CENTER * alpha;
400 n.vx *= DECAY; n.vy *= DECAY;
401 n.x += n.vx; n.y += n.vy;
402 n.x = Math.max(16, Math.min(W - 16, n.x));
403 n.y = Math.max(16, Math.min(H - 16, n.y));
404 }
405 alpha *= (1 - ALPHA_DECAY); // cool toward rest
406 }
407 function loop() {
408 tick(); draw();
409 // keep running while settling, or while a drag is reheating the layout
410 if (alpha > ALPHA_MIN || dragging) requestAnimationFrame(loop);
411 else running = false;
412 }
413 function kick(a = 0.5) { // (re)heat and ensure the loop is running
414 alpha = Math.max(alpha, a);
415 if (!running) { running = true; requestAnimationFrame(loop); }
416 }
417 kick(1);
418
419 // --- interaction ----------------------------------------------------------
420 function highlight(n) {
421 if (!n) {
422 nodeEls.forEach(g => g.classList.remove("dim"));
423 edges.forEach(e => { e.el.classList.remove("hot"); e.el.classList.remove("dim"); });
424 return;
425 }
426 const nb = neighbors.get(n.id);
427 nodes.forEach(m => m.g.classList.toggle("dim", m.id !== n.id && !nb.has(m.id)));
428 edges.forEach(e => {
429 const hot = e.s.id === n.id || e.t.id === n.id;
430 e.el.classList.toggle("hot", hot);
431 e.el.classList.toggle("dim", !hot);
432 });
433 }
434 function select(n) {
435 nodeEls.forEach(g => g.classList.remove("sel"));
436 n.g.classList.add("sel");
437 const list = n.imports;
438 const imps = list.length
439 ? `<ul class="imports">${list.map(i => `<li><code>${esc(i)}</code></li>`).join("")}</ul>`
440 : `<p class="empty">No internal imports.</p>`;
441 document.getElementById("panel").innerHTML =
442 `<h1><span style="color:${COLOR[n.plane] || "#999"}">●</span> <code>${esc(n.id)}</code></h1>` +
443 `<p>${n.synopsis ? esc(n.synopsis) : '<span class="empty">(no package doc)</span>'}</p>` +
444 `<h2>Plane</h2><p>${esc(n.plane)}</p>` +
445 `<h2>Production imports</h2>${imps}` +
446 `<p style="margin-top:18px"><button class="btn" id="hidebtn">hide this node</button></p>`;
447 document.getElementById("hidebtn").onclick = () => hide(n);
448 }
449
450 // --- filtering -----------------------------------------------------------
451 function buildLegend() {
452 const el = document.getElementById("legend");
453 el.innerHTML = "";
454 for (const [k, label, c] of PLANES) {
455 const span = document.createElement("span");
456 span.innerHTML = `<i style="background:${c}"></i>${label}`;
457 span.classList.toggle("off", hiddenPlanes.has(k));
458 span.onclick = () => {
459 hiddenPlanes.has(k) ? hiddenPlanes.delete(k) : hiddenPlanes.add(k);
460 span.classList.toggle("off", hiddenPlanes.has(k));
461 afterFilterChange();
462 };
463 el.appendChild(span);
464 }
465 }
466 function hide(n) {
467 n.hidden = true;
468 document.getElementById("panel").innerHTML =
469 `<p class="hint">Hid <code>${esc(n.id)}</code>. Use “reset filters” to bring it back.</p>`;
470 afterFilterChange();
471 }
472 function resetFilters() {
473 hiddenPlanes.clear();
474 for (const n of nodes) n.hidden = false;
475 buildLegend();
476 afterFilterChange();
477 }
478 function afterFilterChange() {
479 const anyHidden = hiddenPlanes.size > 0 || nodes.some(n => n.hidden);
480 document.getElementById("reset").hidden = !anyHidden;
481 kick(0.4); // re-settle the layout around what remains
482 }
483 document.getElementById("reset").onclick = resetFilters;
484 buildLegend();
485
486 let dragging = null;
487 function pt(ev) {
488 const r = svg.getBoundingClientRect();
489 return { x: (ev.clientX - r.left) / r.width * W, y: (ev.clientY - r.top) / r.height * H };
490 }
491 function startDrag(n, ev) {
492 ev.preventDefault();
493 dragging = n; n.fixed = true; kick(0.3);
494 }
495 window.addEventListener("mousemove", ev => {
496 if (!dragging) return;
497 const p = pt(ev); dragging.x = p.x; dragging.y = p.y;
498 });
499 window.addEventListener("mouseup", () => {
500 if (dragging) { dragging.fixed = false; dragging = null; kick(0.1); }
501 });
502 </script>
503 </body>
504 </html>
docs/shape.json
Old New
@@ -0,0 +1,225 @@
1 {
2 "module": "github.com/a73x/eitri",
3 "packages": [
4 {
5 "importPath": "cmd/eitri-agent",
6 "plane": "binaries",
7 "synopsis": "eitri-agent: BYO-hardware agent.",
8 "imports": [
9 "internal/agent/cloudhv",
10 "internal/agent/imagecache",
11 "internal/agent/netenv",
12 "internal/agent/overlay",
13 "internal/agent/reconcile",
14 "internal/agent/seed",
15 "internal/agent/state",
16 "internal/agent/syncclient"
17 ]
18 },
19 {
20 "importPath": "cmd/eitri-apispec",
21 "plane": "binaries",
22 "synopsis": "Command eitri-apispec regenerates docs/openapi.json from the api route table.",
23 "imports": [
24 "internal/server/api/spec"
25 ]
26 },
27 {
28 "importPath": "cmd/eitri-server",
29 "plane": "binaries",
30 "synopsis": "eitri-server: single-node control plane (Phase 1: static admin token, no TLS termination here — front with a reverse proxy for TLS).",
31 "imports": [
32 "internal/server/api",
33 "internal/server/hub",
34 "internal/server/registry",
35 "internal/server/store",
36 "internal/server/syncsvc",
37 "internal/server/web",
38 "internal/transport"
39 ]
40 },
41 {
42 "importPath": "cmd/eitri-shape",
43 "plane": "binaries",
44 "synopsis": "eitri-shape regenerates the explorable architecture-shape diagram from the real package graph (`go list`).",
45 "imports": [
46 "internal/shape"
47 ]
48 },
49 {
50 "importPath": "internal/agent/cloudhv",
51 "plane": "data",
52 "synopsis": "Package cloudhv manages one cloud-hypervisor process per VM.",
53 "imports": [
54 "internal/agent/exec",
55 "internal/agent/state"
56 ]
57 },
58 {
59 "importPath": "internal/agent/exec",
60 "plane": "data",
61 "synopsis": "Package exec defines the single command-runner type shared by the host-touching agent packages (cloudhv, imagecache, netenv, overlay).",
62 "imports": []
63 },
64 {
65 "importPath": "internal/agent/imagecache",
66 "plane": "data",
67 "synopsis": "Package imagecache downloads, verifies, and raw-converts base images.",
68 "imports": [
69 "internal/agent/exec"
70 ]
71 },
72 {
73 "importPath": "internal/agent/ipalloc",
74 "plane": "data",
75 "synopsis": "Package ipalloc allocates VM IPs within the host's bridge CIDR.",
76 "imports": []
77 },
78 {
79 "importPath": "internal/agent/netenv",
80 "plane": "data",
81 "synopsis": "Package netenv manages the host side of VM networking: bridge eitri0 with the host as .1 gateway, per-VM taps, and NAT for outbound internet.",
82 "imports": [
83 "internal/agent/exec",
84 "internal/agent/ipalloc"
85 ]
86 },
87 {
88 "importPath": "internal/agent/overlay",
89 "plane": "data",
90 "synopsis": "Package overlay abstracts how a host's bridge CIDR becomes reachable from the user's network.",
91 "imports": [
92 "internal/agent/exec"
93 ]
94 },
95 {
96 "importPath": "internal/agent/reconcile",
97 "plane": "data",
98 "synopsis": "Package reconcile implements the agent's level-triggered reconcile loop.",
99 "imports": [
100 "internal/agent/seed",
101 "internal/agent/state",
102 "internal/pb"
103 ]
104 },
105 {
106 "importPath": "internal/agent/seed",
107 "plane": "data",
108 "synopsis": "Package seed builds the cloud-init NoCloud config-drive ISO (label CIDATA).",
109 "imports": []
110 },
111 {
112 "importPath": "internal/agent/state",
113 "plane": "data",
114 "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).",
115 "imports": []
116 },
117 {
118 "importPath": "internal/agent/syncclient",
119 "plane": "data",
120 "synopsis": "Package syncclient holds the agent's stream loop: receive snapshots, run engine steps, send reports.",
121 "imports": [
122 "internal/agent/reconcile",
123 "internal/agent/state",
124 "internal/pb",
125 "internal/transport"
126 ]
127 },
128 {
129 "importPath": "internal/arch",
130 "plane": "tooling",
131 "synopsis": "Package arch holds executable architecture fitness functions for the Eitri module.",
132 "imports": []
133 },
134 {
135 "importPath": "internal/pb",
136 "plane": "wire",
137 "synopsis": "",
138 "imports": []
139 },
140 {
141 "importPath": "internal/server/api",
142 "plane": "control",
143 "synopsis": "Package api implements the admin REST API and the unauthenticated enrollment endpoint.",
144 "imports": [
145 "internal/server/api/types",
146 "internal/server/hosttoken",
147 "internal/server/hub",
148 "internal/server/registry",
149 "internal/server/store"
150 ]
151 },
152 {
153 "importPath": "internal/server/api/spec",
154 "plane": "control",
155 "synopsis": "Package spec projects the api route table into an OpenAPI 3.1 document.",
156 "imports": [
157 "internal/server/api",
158 "internal/server/api/types"
159 ]
160 },
161 {
162 "importPath": "internal/server/api/types",
163 "plane": "control",
164 "synopsis": "Package types is the server HTTP API's wire contract: every request and response JSON shape the API speaks, and nothing else.",
165 "imports": []
166 },
167 {
168 "importPath": "internal/server/hosttoken",
169 "plane": "control",
170 "synopsis": "Package hosttoken mints and verifies host credentials: \"\u003chost_id\u003e.\u003chex hmac-sha256\u003e\".",
171 "imports": []
172 },
173 {
174 "importPath": "internal/server/hub",
175 "plane": "control",
176 "synopsis": "Package hub wakes per-host QUIC streams when desired state changes.",
177 "imports": []
178 },
179 {
180 "importPath": "internal/server/registry",
181 "plane": "control",
182 "synopsis": "Package registry holds volatile actual state in memory.",
183 "imports": []
184 },
185 {
186 "importPath": "internal/server/store",
187 "plane": "control",
188 "synopsis": "Package store is the server's durable control-plane state, backed by SQLite: the host registry, enrollment tokens, desired VM specs, and freed CIDRs.",
189 "imports": [
190 "internal/transport"
191 ]
192 },
193 {
194 "importPath": "internal/server/syncsvc",
195 "plane": "control",
196 "synopsis": "Package syncsvc is the QUIC server end of the agent reconcile stream.",
197 "imports": [
198 "internal/pb",
199 "internal/server/hosttoken",
200 "internal/server/hub",
201 "internal/server/registry",
202 "internal/server/store",
203 "internal/transport"
204 ]
205 },
206 {
207 "importPath": "internal/server/web",
208 "plane": "control",
209 "synopsis": "Package web embeds the built SvelteKit single-page app and serves it with SPA-style fallback (unknown paths resolve to index.html for client routing).",
210 "imports": []
211 },
212 {
213 "importPath": "internal/shape",
214 "plane": "tooling",
215 "synopsis": "Package shape generates an explorable diagram of eitri's package graph from the real `go list` output, so the architecture view cannot silently drift from the code.",
216 "imports": []
217 },
218 {
219 "importPath": "internal/transport",
220 "plane": "wire",
221 "synopsis": "Package transport carries the agent↔server sync protocol over QUIC.",
222 "imports": []
223 }
224 ]
225 }
internal/arch/arch_test.go
Old New
@@ -0,0 +1,238 @@
1 package arch
2
3 import (
4 "os/exec"
5 "sort"
6 "strings"
7 "testing"
8 )
9
10 // module is the import-path prefix shared by every package in this repo.
11 const module = "github.com/a73x/eitri"
12
13 // directImports returns, for every package under internal/... and cmd/..., the
14 // list of packages it imports directly (including stdlib). Edges are taken from
15 // `go list`, so this reflects the real, compiled dependency graph rather than a
16 // hand-maintained description that can drift.
17 //
18 // The query uses absolute module patterns (not ./...) so it is independent of
19 // the test's working directory.
20 //
21 // NOTE: because the dependency graph is gathered by shelling out rather than
22 // from this package's own source files, Go's test cache cannot tell when an
23 // edge elsewhere in the module changes. Always run these tests with -count=1
24 // (the `make arch` target and CI do); a plain `go test ./...` may serve a stale
25 // cached result.
26 func directImports(t *testing.T) map[string][]string {
27 t.Helper()
28 out, err := exec.Command("go", "list",
29 "-f", `{{.ImportPath}} {{join .Imports " "}}`,
30 module+"/internal/...", module+"/cmd/...").CombinedOutput()
31 if err != nil {
32 t.Fatalf("go list failed: %v\n%s", err, out)
33 }
34 graph := map[string][]string{}
35 for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
36 fields := strings.Fields(line)
37 if len(fields) == 0 {
38 continue
39 }
40 graph[fields[0]] = fields[1:]
41 }
42 return graph
43 }
44
45 // internalImports keeps only edges to other packages in this module — the ones
46 // that express our own layering. Stdlib and third-party edges are dropped.
47 func internalImports(t *testing.T) map[string][]string {
48 t.Helper()
49 g := directImports(t)
50 internal := map[string][]string{}
51 for pkg, deps := range g {
52 for _, d := range deps {
53 if strings.HasPrefix(d, module+"/") {
54 internal[pkg] = append(internal[pkg], d)
55 }
56 }
57 }
58 return internal
59 }
60
61 // transitiveDeps returns every internal package reachable from pkg, following
62 // internal edges. Used for rules that must hold through the whole dependency
63 // chain (e.g. "no server package may reach an agent package, however indirectly").
64 func transitiveDeps(graph map[string][]string, pkg string) map[string]bool {
65 seen := map[string]bool{}
66 var walk func(string)
67 walk = func(p string) {
68 for _, d := range graph[p] {
69 if !seen[d] {
70 seen[d] = true
71 walk(d)
72 }
73 }
74 }
75 walk(pkg)
76 return seen
77 }
78
79 // short trims the module prefix for readable failure messages.
80 func short(pkg string) string { return strings.TrimPrefix(pkg, module+"/") }
81
82 func has(pkg, sub string) bool { return strings.Contains(short(pkg), sub) }
83
84 // R1: the control plane (internal/server/*) and the data plane (internal/agent/*)
85 // are independently deployable binaries on different hosts. They must never
86 // import each other — even transitively. The only code they may share is the
87 // wire contract (internal/pb, internal/transport); see R3.
88 func TestControlAndDataPlaneAreDisjoint(t *testing.T) {
89 g := internalImports(t)
90 for pkg := range g {
91 deps := transitiveDeps(g, pkg)
92 switch {
93 case has(pkg, "internal/server/"):
94 for d := range deps {
95 if has(d, "internal/agent/") {
96 t.Errorf("control-plane package %s must not import data-plane package %s", short(pkg), short(d))
97 }
98 }
99 case has(pkg, "internal/agent/"):
100 for d := range deps {
101 if has(d, "internal/server/") {
102 t.Errorf("data-plane package %s must not import control-plane package %s", short(pkg), short(d))
103 }
104 }
105 }
106 }
107 }
108
109 // R3: the wire contract is the only thing shared across the two planes, so it
110 // must stay dependency-light and leaf-like. internal/pb (generated protobuf)
111 // and internal/transport (framing + TLS) must import no other internal package
112 // — otherwise a heavy dependency (e.g. the SQLite store) would leak across the
113 // plane boundary into both binaries.
114 func TestWireContractIsLeaf(t *testing.T) {
115 g := internalImports(t)
116 for _, leaf := range []string{module + "/internal/pb", module + "/internal/transport"} {
117 for _, d := range g[leaf] {
118 t.Errorf("%s must not import any internal package, but imports %s", short(leaf), short(d))
119 }
120 }
121 }
122
123 // R4: domain/state packages hold pure logic and must not depend on the
124 // transport stack (HTTP, QUIC) or its serialization concerns. This keeps them
125 // trivially testable and serialization-agnostic.
126 //
127 // internal/server/store is a domain package that legitimately imports
128 // internal/transport today for cert/fingerprint helpers, so it is asserted
129 // separately: it may touch transport but still must not reach for net/http or
130 // QUIC directly.
131 func TestDomainDoesNotImportTransportStack(t *testing.T) {
132 g := directImports(t)
133
134 pureDomain := []string{
135 module + "/internal/agent/state",
136 module + "/internal/agent/seed",
137 module + "/internal/agent/ipalloc",
138 module + "/internal/server/registry",
139 }
140 forbiddenForPure := []string{
141 "net/http",
142 "github.com/quic-go/quic-go",
143 module + "/internal/transport",
144 }
145 for _, pkg := range pureDomain {
146 assertNotImported(t, g, pkg, forbiddenForPure)
147 }
148
149 // store may use transport (documented), but not the live network stack.
150 assertNotImported(t, g, module+"/internal/server/store", []string{
151 "net/http",
152 "github.com/quic-go/quic-go",
153 })
154 }
155
156 // R2: the server is a pure control plane. It expresses intent as desired state
157 // and never actuates VMs itself, so no package under internal/server may shell
158 // out. A direct import of os/exec is the canonical signal of a violation.
159 func TestServerNeverShellsOut(t *testing.T) {
160 g := directImports(t)
161 for pkg, deps := range g {
162 if !has(pkg, "internal/server/") {
163 continue
164 }
165 for _, d := range deps {
166 if d == "os/exec" {
167 t.Errorf("control-plane package %s must not import os/exec — the server never shells out", short(pkg))
168 }
169 }
170 }
171 }
172
173 // R6: all external process execution in the data plane funnels through
174 // agent/exec.Runner, which keeps the host-touching packages mockable and
175 // auditable. The sole exception is agent/cloudhv, which launches the
176 // long-lived cloud-hypervisor process directly (exec.CommandContext) rather
177 // than through the one-shot Runner. Every other agent package must use Runner
178 // and must not import os/exec.
179 func TestOnlyCloudhvImportsOsExecInDataPlane(t *testing.T) {
180 g := directImports(t)
181 allowed := module + "/internal/agent/cloudhv"
182 for pkg, deps := range g {
183 if !has(pkg, "internal/agent/") || pkg == allowed {
184 continue
185 }
186 for _, d := range deps {
187 if d == "os/exec" {
188 t.Errorf("data-plane package %s must not import os/exec — use agent/exec.Runner instead", short(pkg))
189 }
190 }
191 }
192 }
193
194 // internal/server/api/types is a leaf — stdlib imports only. The contract
195 // is consumed by the spec generator, the client, and the handlers; a single
196 // internal import would drag server internals into every consumer at once and
197 // break the reflection-based OpenAPI generator's "types package = the whole
198 // wire" guarantee (walking the package would surface types that are not wire
199 // contract at all). R9 tells the same story for the wire plane; this is the
200 // API contract's own copy of it.
201 func TestAPITypesIsALeaf(t *testing.T) {
202 g := directImports(t)
203 target := module + "/internal/server/api/types"
204 deps, ok := g[target]
205 if !ok {
206 // A typo or rename would silently pass; guard it.
207 t.Fatalf("package %s not found in import graph (renamed or removed?)", short(target))
208 }
209 for _, d := range deps {
210 if strings.HasPrefix(d, module+"/") {
211 t.Errorf("contract package %s must import only the standard library, but imports %s", short(target), short(d))
212 }
213 }
214 }
215
216 // assertNotImported fails if pkg directly imports any path in forbidden.
217 func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) {
218 t.Helper()
219 deps := g[pkg]
220 if deps == nil {
221 // A typo in a package path would silently pass every rule; guard it.
222 t.Fatalf("package %s not found in import graph (renamed or removed?)", short(pkg))
223 }
224 bad := map[string]bool{}
225 for _, f := range forbidden {
226 bad[f] = true
227 }
228 var hits []string
229 for _, d := range deps {
230 if bad[d] {
231 hits = append(hits, d)
232 }
233 }
234 if len(hits) > 0 {
235 sort.Strings(hits)
236 t.Errorf("domain package %s must not import: %s", short(pkg), strings.Join(hits, ", "))
237 }
238 }
internal/arch/doc.go
Old New
@@ -0,0 +1,7 @@
1 // Package arch holds executable architecture fitness functions for the Eitri
2 // module. It contains no production code — only tests (arch_test.go) that read
3 // the real package import graph via `go list` and fail the build when a
4 // documented architectural invariant is violated.
5 //
6 // The invariants and their rationale are documented in docs/architecture.md.
7 package arch
internal/server/store/store.go
Old New
@@ -1,3 +1,7 @@
1 // Package store is the server's durable control-plane state, backed by SQLite:
2 // the host registry, enrollment tokens, desired VM specs, and freed CIDRs. It
3 // owns what the fleet should be (desired state); the live actual state reported
4 // by agents is held in memory by package registry.
1 package store 5 package store
2 6
3 import ( 7 import (
internal/shape/build.go
Old New
@@ -0,0 +1,34 @@
1 package shape
2
3 import (
4 "sort"
5 "strings"
6 )
7
8 // Build converts a raw `go list` package set into the deterministic Model:
9 // module prefix stripped, plane classified, doc synopsized, internal imports
10 // kept (stdlib/external dropped). Every slice is sorted so identical input —
11 // in any order — yields byte-identical output.
12 func Build(raw []rawPackage) Model {
13 pkgs := make([]Package, 0, len(raw))
14 for _, r := range raw {
15 rel := strings.TrimPrefix(r.ImportPath, module+"/")
16 // Non-nil so a package with no internal imports marshals to [] not
17 // null — the viewer's JS iterates this field and would crash on null.
18 imps := make([]string, 0, len(r.Imports))
19 for _, imp := range r.Imports {
20 if strings.HasPrefix(imp, module+"/") {
21 imps = append(imps, strings.TrimPrefix(imp, module+"/"))
22 }
23 }
24 sort.Strings(imps)
25 pkgs = append(pkgs, Package{
26 ImportPath: rel,
27 Plane: classify(rel),
28 Synopsis: synopsis(r.Doc),
29 Imports: imps,
30 })
31 }
32 sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].ImportPath < pkgs[j].ImportPath })
33 return Model{Module: module, Packages: pkgs}
34 }
internal/shape/build_test.go
Old New
@@ -0,0 +1,85 @@
1 package shape
2
3 import (
4 "bytes"
5 "encoding/json"
6 "testing"
7 )
8
9 func sample() []rawPackage {
10 return []rawPackage{
11 {
12 ImportPath: module + "/internal/server/api",
13 Doc: "Package api serves the control plane HTTP API. More text.",
14 Imports: []string{"net/http", module + "/internal/pb", module + "/internal/server/store"},
15 },
16 {
17 ImportPath: module + "/internal/pb",
18 Doc: "",
19 Imports: []string{"google.golang.org/protobuf/runtime/protoimpl"},
20 },
21 }
22 }
23
24 func TestBuildStripsModuleClassifiesAndKeepsInternalImports(t *testing.T) {
25 m := Build(sample())
26 if m.Module != module {
27 t.Fatalf("Module = %q, want %q", m.Module, module)
28 }
29 if len(m.Packages) != 2 {
30 t.Fatalf("got %d packages, want 2", len(m.Packages))
31 }
32 // Packages are sorted by import path; find api explicitly.
33 var found Package
34 for _, p := range m.Packages {
35 if p.ImportPath == "internal/server/api" {
36 found = p
37 }
38 }
39 if found.Plane != PlaneControl {
40 t.Errorf("api plane = %q, want control", found.Plane)
41 }
42 if found.Synopsis != "Package api serves the control plane HTTP API." {
43 t.Errorf("api synopsis = %q", found.Synopsis)
44 }
45 // stdlib (net/http) and external imports dropped; internal kept + relative + sorted.
46 want := []string{"internal/pb", "internal/server/store"}
47 if len(found.Imports) != len(want) {
48 t.Fatalf("imports = %v, want %v", found.Imports, want)
49 }
50 for i := range want {
51 if found.Imports[i] != want[i] {
52 t.Errorf("imports[%d] = %q, want %q", i, found.Imports[i], want[i])
53 }
54 }
55 }
56
57 // A package with no internal imports must serialize as [] (empty array), not
58 // null. A nil Go slice marshals to JSON null, which the viewer's JS would try
59 // to iterate (`for (const imp of null)`) and crash on, blanking the whole
60 // diagram. So Imports must be a non-nil empty slice.
61 func TestBuildEmitsEmptyImportsArrayNotNull(t *testing.T) {
62 m := Build([]rawPackage{
63 {ImportPath: module + "/internal/pb", Imports: []string{"google.golang.org/protobuf/runtime/protoimpl"}},
64 })
65 if m.Packages[0].Imports == nil {
66 t.Fatal("Imports is nil; want non-nil empty slice")
67 }
68 b, err := json.Marshal(m.Packages[0])
69 if err != nil {
70 t.Fatal(err)
71 }
72 if !bytes.Contains(b, []byte(`"imports":[]`)) {
73 t.Errorf("expected \"imports\":[] in JSON, got: %s", b)
74 }
75 }
76
77 func TestBuildIsDeterministicRegardlessOfInputOrder(t *testing.T) {
78 a := sample()
79 b := []rawPackage{a[1], a[0]} // reversed input order
80 ja, _ := json.Marshal(Build(a))
81 jb, _ := json.Marshal(Build(b))
82 if !bytes.Equal(ja, jb) {
83 t.Errorf("Build output depends on input order:\n a=%s\n b=%s", ja, jb)
84 }
85 }
internal/shape/classify.go
Old New
@@ -0,0 +1,44 @@
1 package shape
2
3 import "strings"
4
5 // synopsis returns the first sentence of a package doc string: first paragraph
6 // only, internal whitespace collapsed, truncated at the first period that is
7 // followed by a space or the end of the text. Empty input yields "".
8 func synopsis(doc string) string {
9 if i := strings.Index(doc, "\n\n"); i >= 0 {
10 doc = doc[:i]
11 }
12 doc = strings.Join(strings.Fields(doc), " ")
13 for i := 0; i < len(doc); i++ {
14 if doc[i] == '.' && (i+1 == len(doc) || doc[i+1] == ' ') {
15 return doc[:i+1]
16 }
17 }
18 return doc
19 }
20
21 // classify returns the plane for a module-relative import path (the path with
22 // the module prefix already stripped, e.g. "internal/server/api"). Matching is
23 // by prefix, first match wins, so sub-packages inherit their parent's plane.
24 // Anything matching no rule lands in PlaneUnclassified — a first-class bucket,
25 // never silently dropped (see TestNoUnclassifiedPackagesInModule).
26 func classify(rel string) Plane {
27 switch {
28 case strings.HasPrefix(rel, "internal/server"):
29 return PlaneControl
30 case strings.HasPrefix(rel, "internal/agent"):
31 return PlaneData
32 case strings.HasPrefix(rel, "internal/pb"),
33 strings.HasPrefix(rel, "internal/transport"):
34 return PlaneWire
35 case strings.HasPrefix(rel, "cmd/"):
36 return PlaneBinaries
37 case strings.HasPrefix(rel, "internal/arch"),
38 strings.HasPrefix(rel, "internal/integration"),
39 strings.HasPrefix(rel, "internal/shape"):
40 return PlaneTooling
41 default:
42 return PlaneUnclassified
43 }
44 }
internal/shape/classify_test.go
Old New
@@ -0,0 +1,46 @@
1 package shape
2
3 import "testing"
4
5 func TestSynopsisTakesFirstSentence(t *testing.T) {
6 cases := map[string]string{
7 "": "",
8 "Package reconcile does X.": "Package reconcile does X.",
9 "First sentence. Second one.": "First sentence.",
10 "Two\nlines collapsed.": "Two lines collapsed.",
11 "Para one.\n\nPara two.": "Para one.",
12 // A garbled run-on like devstack's: cut at the first ". " boundary,
13 // dropping the trailing concatenated section.
14 "Brings up the stack until Ctrl-C. It is the companion — both ride harness.Stack blocks.": "Brings up the stack until Ctrl-C.",
15 }
16 for in, want := range cases {
17 if got := synopsis(in); got != want {
18 t.Errorf("synopsis(%q) = %q, want %q", in, got, want)
19 }
20 }
21 }
22
23 func TestClassifyAssignsPlaneByPrefix(t *testing.T) {
24 cases := map[string]Plane{
25 "internal/server/api": PlaneControl,
26 "internal/server/store": PlaneControl,
27 "internal/agent/reconcile": PlaneData,
28 "internal/agent/exec": PlaneData,
29 "internal/pb": PlaneWire,
30 "internal/transport": PlaneWire,
31 "cmd/eitri-server": PlaneBinaries,
32 "cmd/eitri-shape": PlaneBinaries,
33 "internal/arch": PlaneTooling,
34 "internal/integration": PlaneTooling,
35 "internal/integration/harness": PlaneTooling,
36 "internal/integration/sandbox": PlaneTooling,
37 "internal/shape": PlaneTooling,
38 "internal/somethingnew": PlaneUnclassified,
39 "pkg/whatever": PlaneUnclassified,
40 }
41 for rel, want := range cases {
42 if got := classify(rel); got != want {
43 t.Errorf("classify(%q) = %q, want %q", rel, got, want)
44 }
45 }
46 }
internal/shape/generate.go
Old New
@@ -0,0 +1,11 @@
1 package shape
2
3 // Generate runs the real `go list`, builds the model, and returns its JSON
4 // rendering. It is the one entry point cmd/eitri-shape calls.
5 func Generate() ([]byte, error) {
6 raw, err := goListLister()()
7 if err != nil {
8 return nil, err
9 }
10 return RenderJSON(Build(raw))
11 }
internal/shape/golist.go
Old New
@@ -0,0 +1,36 @@
1 package shape
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "os"
8 "os/exec"
9 )
10
11 // goListLister returns a lister backed by `go list -json` over this module's
12 // internal/... and cmd/... packages. GOOS is pinned to linux: the stack is
13 // Linux-only (netlink, cloud-hypervisor), so pinning keeps output identical
14 // regardless of the contributor's OS. `go list` only resolves build
15 // constraints here — it does not compile — so this is safe cross-platform.
16 func goListLister() lister {
17 return func() ([]rawPackage, error) {
18 cmd := exec.Command("go", "list", "-json",
19 module+"/internal/...", module+"/cmd/...")
20 cmd.Env = append(os.Environ(), "GOOS=linux")
21 out, err := cmd.Output()
22 if err != nil {
23 return nil, fmt.Errorf("go list: %w", err)
24 }
25 var pkgs []rawPackage
26 dec := json.NewDecoder(bytes.NewReader(out))
27 for dec.More() {
28 var p rawPackage
29 if err := dec.Decode(&p); err != nil {
30 return nil, fmt.Errorf("decode go list output: %w", err)
31 }
32 pkgs = append(pkgs, p)
33 }
34 return pkgs, nil
35 }
36 }
internal/shape/golist_test.go
Old New
@@ -0,0 +1,33 @@
1 package shape
2
3 import "testing"
4
5 // Shells out to the real `go list`; asserts the live module classifies cleanly.
6 // If a new top-level package appears that matches no rule in classify(), it
7 // lands in Unclassified and this fails — forcing a deliberate classification.
8 func TestNoUnclassifiedPackagesInModule(t *testing.T) {
9 raw, err := goListLister()()
10 if err != nil {
11 t.Fatalf("go list: %v", err)
12 }
13 m := Build(raw)
14 if len(m.Packages) == 0 {
15 t.Fatal("go list returned no packages")
16 }
17 for _, p := range m.Packages {
18 if p.Plane == PlaneUnclassified {
19 t.Errorf("package %q is unclassified — add a rule to classify()", p.ImportPath)
20 }
21 }
22 }
23
24 func TestGenerateProducesStableJSON(t *testing.T) {
25 a, err := Generate()
26 if err != nil {
27 t.Fatal(err)
28 }
29 b, _ := Generate()
30 if string(a) != string(b) {
31 t.Error("Generate output is not stable across calls")
32 }
33 }
internal/shape/render.go
Old New
@@ -0,0 +1,30 @@
1 package shape
2
3 import (
4 _ "embed"
5 "encoding/json"
6 "strings"
7 )
8
9 //go:embed viewer.html
10 var viewerTemplate string
11
12 // dataMarker is the placeholder in viewer.html replaced with the JSON model.
13 const dataMarker = "/*SHAPE_DATA*/"
14
15 // RenderJSON marshals the model to pretty-printed, deterministic JSON with a
16 // trailing newline. The model contains only sorted slices (no maps), so output
17 // is byte-stable across runs.
18 func RenderJSON(m Model) ([]byte, error) {
19 b, err := json.MarshalIndent(m, "", " ")
20 if err != nil {
21 return nil, err
22 }
23 return append(b, '\n'), nil
24 }
25
26 // RenderHTML inlines the exact JSON bytes into the static viewer template, so
27 // the HTML cannot drift independently of the JSON.
28 func RenderHTML(jsonBytes []byte) string {
29 return strings.Replace(viewerTemplate, dataMarker, string(jsonBytes), 1)
30 }
internal/shape/render_test.go
Old New
@@ -0,0 +1,37 @@
1 package shape
2
3 import (
4 "strings"
5 "testing"
6 )
7
8 func TestRenderJSONIsSortedAndStable(t *testing.T) {
9 j1, err := RenderJSON(Build(sample()))
10 if err != nil {
11 t.Fatal(err)
12 }
13 j2, _ := RenderJSON(Build(sample()))
14 if string(j1) != string(j2) {
15 t.Fatal("RenderJSON not stable across calls")
16 }
17 if !strings.HasSuffix(string(j1), "\n") {
18 t.Error("RenderJSON output must end with a trailing newline")
19 }
20 if !strings.Contains(string(j1), `"importPath": "internal/pb"`) {
21 t.Errorf("expected pretty-printed importPath in output:\n%s", j1)
22 }
23 }
24
25 func TestRenderHTMLInlinesExactJSONBytes(t *testing.T) {
26 j, _ := RenderJSON(Build(sample()))
27 html := RenderHTML(j)
28 if !strings.Contains(html, string(j)) {
29 t.Error("HTML must contain the JSON bytes verbatim")
30 }
31 if strings.Contains(html, dataMarker) {
32 t.Error("data marker must be replaced")
33 }
34 if !strings.Contains(html, `id="shape-data"`) {
35 t.Error("viewer template missing shape-data script block")
36 }
37 }
internal/shape/shape.go
Old New
@@ -0,0 +1,45 @@
1 // Package shape generates an explorable diagram of eitri's package graph from
2 // the real `go list` output, so the architecture view cannot silently drift
3 // from the code.
4 package shape
5
6 // Plane is the architectural group a package belongs to.
7 type Plane string
8
9 const (
10 PlaneControl Plane = "control"
11 PlaneData Plane = "data"
12 PlaneWire Plane = "wire"
13 PlaneBinaries Plane = "binaries"
14 PlaneTooling Plane = "tooling"
15 PlaneUnclassified Plane = "unclassified"
16 )
17
18 // module is the import-path prefix shared by every package in this repo.
19 const module = "github.com/a73x/eitri"
20
21 // rawPackage is the subset of `go list -json` fields the generator consumes.
22 type rawPackage struct {
23 ImportPath string
24 Doc string
25 Imports []string
26 }
27
28 // lister returns the raw package list for the module. It is a concrete function
29 // type (not an interface) so injecting a fake in tests does not trip the
30 // block-tier ireturn linter, and so production code can shell out to `go list`.
31 type lister func() ([]rawPackage, error)
32
33 // Package is one node in the shape graph. All string fields are module-relative.
34 type Package struct {
35 ImportPath string `json:"importPath"`
36 Plane Plane `json:"plane"`
37 Synopsis string `json:"synopsis"`
38 Imports []string `json:"imports"` // internal (production) imports, sorted
39 }
40
41 // Model is the full, deterministic shape graph.
42 type Model struct {
43 Module string `json:"module"`
44 Packages []Package `json:"packages"` // sorted by ImportPath
45 }
internal/shape/viewer.html
Old New
@@ -0,0 +1,279 @@
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <title>eitri — architecture shape</title>
6 <style>
7 :root { font-family: system-ui, sans-serif; }
8 body { margin: 0; display: flex; height: 100vh; color: #1a1a1a; overflow: hidden; }
9 #main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
10 header.top { padding: 12px 16px; border-bottom: 1px solid #eee; display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; }
11 header.top h1 { font-size: 15px; margin: 0; }
12 header.top .mod { color: #888; font-size: 12px; font-family: ui-monospace, monospace; }
13 #legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; align-items: center; }
14 #legend span { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none; }
15 #legend span.off { opacity: 0.4; text-decoration: line-through; }
16 #legend i { width: 11px; height: 11px; border-radius: 50%; display: inline-block; }
17 .btn { font: inherit; font-size: 12px; padding: 2px 9px; border: 1px solid #ccc; border-radius: 5px;
18 background: #fff; color: #333; cursor: pointer; }
19 .btn:hover { background: #f0f0f0; }
20 #reset[hidden] { display: none; }
21 #canvas { flex: 1; min-height: 0; }
22 svg { width: 100%; height: 100%; display: block; background: #fcfcfc; cursor: grab; }
23 .edge { stroke: #c8c8cf; stroke-width: 1; }
24 .edge.hot { stroke: #333; stroke-width: 1.6; }
25 .node { cursor: pointer; }
26 .node circle { stroke: #fff; stroke-width: 1.5; }
27 .node text { font-size: 9px; fill: #333; pointer-events: none; font-family: ui-monospace, monospace; }
28 .node.dim { opacity: 0.18; }
29 .edge.dim { opacity: 0.07; }
30 .node.sel circle { stroke: #111; stroke-width: 2.5; }
31 #panel { width: 320px; border-left: 1px solid #ddd; padding: 20px; overflow: auto; background: #fafafa; }
32 #panel h2 { font-size: 13px; margin: 16px 0 6px; color: #444; }
33 #panel h1 { font-size: 14px; margin: 0 0 8px; }
34 code { font-family: ui-monospace, monospace; font-size: 12px; }
35 .imports { margin: 0; padding-left: 18px; }
36 .imports li { font-size: 12px; }
37 .empty { color: #888; }
38 .hint { color: #999; font-size: 12px; }
39 </style>
40 </head>
41 <body>
42 <div id="main">
43 <header class="top">
44 <h1>eitri — architecture shape <span class="mod" id="modtag"></span></h1>
45 <div id="legend"></div>
46 <button id="reset" class="btn" hidden>reset filters</button>
47 </header>
48 <div id="canvas"></div>
49 </div>
50 <div id="panel"><p class="hint">Drag to pull the graph apart. Hover a node to trace its edges. Click for details.</p></div>
51 <script type="application/json" id="shape-data">
52 /*SHAPE_DATA*/
53 </script>
54 <script>
55 const PLANES = [
56 ["control", "control", "#4571c4"], ["data", "data", "#d04a4a"],
57 ["wire", "wire", "#2fa85a"], ["binaries", "binaries", "#8a4fd0"],
58 ["tooling", "tooling", "#7a7a7a"], ["unclassified", "unclassified", "#d4a017"],
59 ];
60 const COLOR = Object.fromEntries(PLANES.map(([k, , c]) => [k, c]));
61 const esc = s => String(s).replace(/[&<>]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" }[c]));
62
63 const model = JSON.parse(document.getElementById("shape-data").textContent);
64 document.getElementById("modtag").textContent = model.module;
65
66 // --- filter state --------------------------------------------------------
67 // A node is in the simulation/render iff it is not individually hidden and its
68 // plane is not toggled off. Hidden nodes are dropped from forces and edges, so
69 // the layout genuinely re-flows around what remains.
70 const hiddenPlanes = new Set();
71 const visible = n => !n.hidden && !hiddenPlanes.has(n.plane);
72
73 // --- build node + edge sets ---------------------------------------------
74 const W = 1000, H = 700;
75 const nodes = model.packages.map((p, i) => ({
76 id: p.importPath, plane: p.plane, synopsis: p.synopsis, imports: p.imports || [],
77 // deterministic initial placement on a circle (no RNG → reproducible layout)
78 x: W / 2 + Math.cos(i / model.packages.length * 2 * Math.PI) * 250,
79 y: H / 2 + Math.sin(i / model.packages.length * 2 * Math.PI) * 250,
80 vx: 0, vy: 0, fixed: false, hidden: false,
81 }));
82 const byId = Object.fromEntries(nodes.map(n => [n.id, n]));
83 const edges = [];
84 for (const n of nodes)
85 for (const imp of n.imports)
86 if (byId[imp]) edges.push({ s: n, t: byId[imp] });
87 const neighbors = new Map(nodes.map(n => [n.id, new Set()]));
88 for (const e of edges) { neighbors.get(e.s.id).add(e.t.id); neighbors.get(e.t.id).add(e.s.id); }
89
90 // --- SVG ------------------------------------------------------------------
91 const SVGNS = "http://www.w3.org/2000/svg";
92 const svg = document.createElementNS(SVGNS, "svg");
93 svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
94 svg.innerHTML = `<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5"
95 markerWidth="7" markerHeight="7" orient="auto-start-reverse">
96 <path d="M0,0 L10,5 L0,10 z" fill="#c8c8cf"/></marker></defs>`;
97 document.getElementById("canvas").appendChild(svg);
98
99 const edgeEls = edges.map(e => {
100 const l = document.createElementNS(SVGNS, "line");
101 l.setAttribute("class", "edge");
102 l.setAttribute("marker-end", "url(#arrow)");
103 svg.appendChild(l);
104 e.el = l;
105 return l;
106 });
107 const nodeEls = nodes.map(n => {
108 const g = document.createElementNS(SVGNS, "g");
109 g.setAttribute("class", "node");
110 const c = document.createElementNS(SVGNS, "circle");
111 c.setAttribute("r", 7);
112 c.setAttribute("fill", COLOR[n.plane] || "#999");
113 const t = document.createElementNS(SVGNS, "text");
114 t.setAttribute("x", 10); t.setAttribute("y", 3);
115 t.textContent = n.id.split("/").pop();
116 g.appendChild(c); g.appendChild(t);
117 svg.appendChild(g);
118 n.g = g;
119 g.addEventListener("mouseenter", () => highlight(n));
120 g.addEventListener("mouseleave", () => highlight(null));
121 g.addEventListener("mousedown", ev => startDrag(n, ev));
122 g.addEventListener("click", () => select(n));
123 return g;
124 });
125
126 function draw() {
127 for (const e of edges) {
128 if (!visible(e.s) || !visible(e.t)) { e.el.style.display = "none"; continue; }
129 e.el.style.display = "";
130 e.el.setAttribute("x1", e.s.x); e.el.setAttribute("y1", e.s.y);
131 e.el.setAttribute("x2", e.t.x); e.el.setAttribute("y2", e.t.y);
132 }
133 for (const n of nodes) {
134 n.g.style.display = visible(n) ? "" : "none";
135 n.g.setAttribute("transform", `translate(${n.x},${n.y})`);
136 }
137 }
138
139 // --- force simulation (velocity + alpha-decay, settles to rest) ----------
140 const REPULSION = 6000; // node-node push (charge)
141 const LINK_DIST = 140; // ideal edge length
142 const LINK_STR = 0.04; // edge spring stiffness
143 const CENTER = 0.015; // gravity toward centre (keeps graph on-screen)
144 const DECAY = 0.6; // velocity damping per tick
145 const ALPHA_DECAY = 0.02, ALPHA_MIN = 0.003;
146 let alpha = 1, running = false;
147
148 function tick() {
149 // node-node repulsion (all pairs; n=30 so O(n²) is trivial)
150 for (let i = 0; i < nodes.length; i++)
151 for (let j = i + 1; j < nodes.length; j++) {
152 const a = nodes[i], b = nodes[j];
153 if (!visible(a) || !visible(b)) continue;
154 let dx = a.x - b.x, dy = a.y - b.y;
155 const d2 = dx * dx + dy * dy + 1, d = Math.sqrt(d2);
156 const f = REPULSION / d2 * alpha;
157 const ux = dx / d * f, uy = dy / d * f;
158 a.vx += ux; a.vy += uy; b.vx -= ux; b.vy -= uy;
159 }
160 // edge springs toward LINK_DIST
161 for (const e of edges) {
162 if (!visible(e.s) || !visible(e.t)) continue;
163 let dx = e.t.x - e.s.x, dy = e.t.y - e.s.y;
164 const d = Math.hypot(dx, dy) || 0.01;
165 const f = (d - LINK_DIST) * LINK_STR * alpha;
166 const ux = dx / d * f, uy = dy / d * f;
167 e.s.vx += ux; e.s.vy += uy; e.t.vx -= ux; e.t.vy -= uy;
168 }
169 // gravity + integrate with damping
170 for (const n of nodes) {
171 if (!visible(n)) continue;
172 if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
173 n.vx += (W / 2 - n.x) * CENTER * alpha;
174 n.vy += (H / 2 - n.y) * CENTER * alpha;
175 n.vx *= DECAY; n.vy *= DECAY;
176 n.x += n.vx; n.y += n.vy;
177 n.x = Math.max(16, Math.min(W - 16, n.x));
178 n.y = Math.max(16, Math.min(H - 16, n.y));
179 }
180 alpha *= (1 - ALPHA_DECAY); // cool toward rest
181 }
182 function loop() {
183 tick(); draw();
184 // keep running while settling, or while a drag is reheating the layout
185 if (alpha > ALPHA_MIN || dragging) requestAnimationFrame(loop);
186 else running = false;
187 }
188 function kick(a = 0.5) { // (re)heat and ensure the loop is running
189 alpha = Math.max(alpha, a);
190 if (!running) { running = true; requestAnimationFrame(loop); }
191 }
192 kick(1);
193
194 // --- interaction ----------------------------------------------------------
195 function highlight(n) {
196 if (!n) {
197 nodeEls.forEach(g => g.classList.remove("dim"));
198 edges.forEach(e => { e.el.classList.remove("hot"); e.el.classList.remove("dim"); });
199 return;
200 }
201 const nb = neighbors.get(n.id);
202 nodes.forEach(m => m.g.classList.toggle("dim", m.id !== n.id && !nb.has(m.id)));
203 edges.forEach(e => {
204 const hot = e.s.id === n.id || e.t.id === n.id;
205 e.el.classList.toggle("hot", hot);
206 e.el.classList.toggle("dim", !hot);
207 });
208 }
209 function select(n) {
210 nodeEls.forEach(g => g.classList.remove("sel"));
211 n.g.classList.add("sel");
212 const list = n.imports;
213 const imps = list.length
214 ? `<ul class="imports">${list.map(i => `<li><code>${esc(i)}</code></li>`).join("")}</ul>`
215 : `<p class="empty">No internal imports.</p>`;
216 document.getElementById("panel").innerHTML =
217 `<h1><span style="color:${COLOR[n.plane] || "#999"}">●</span> <code>${esc(n.id)}</code></h1>` +
218 `<p>${n.synopsis ? esc(n.synopsis) : '<span class="empty">(no package doc)</span>'}</p>` +
219 `<h2>Plane</h2><p>${esc(n.plane)}</p>` +
220 `<h2>Production imports</h2>${imps}` +
221 `<p style="margin-top:18px"><button class="btn" id="hidebtn">hide this node</button></p>`;
222 document.getElementById("hidebtn").onclick = () => hide(n);
223 }
224
225 // --- filtering -----------------------------------------------------------
226 function buildLegend() {
227 const el = document.getElementById("legend");
228 el.innerHTML = "";
229 for (const [k, label, c] of PLANES) {
230 const span = document.createElement("span");
231 span.innerHTML = `<i style="background:${c}"></i>${label}`;
232 span.classList.toggle("off", hiddenPlanes.has(k));
233 span.onclick = () => {
234 hiddenPlanes.has(k) ? hiddenPlanes.delete(k) : hiddenPlanes.add(k);
235 span.classList.toggle("off", hiddenPlanes.has(k));
236 afterFilterChange();
237 };
238 el.appendChild(span);
239 }
240 }
241 function hide(n) {
242 n.hidden = true;
243 document.getElementById("panel").innerHTML =
244 `<p class="hint">Hid <code>${esc(n.id)}</code>. Use “reset filters” to bring it back.</p>`;
245 afterFilterChange();
246 }
247 function resetFilters() {
248 hiddenPlanes.clear();
249 for (const n of nodes) n.hidden = false;
250 buildLegend();
251 afterFilterChange();
252 }
253 function afterFilterChange() {
254 const anyHidden = hiddenPlanes.size > 0 || nodes.some(n => n.hidden);
255 document.getElementById("reset").hidden = !anyHidden;
256 kick(0.4); // re-settle the layout around what remains
257 }
258 document.getElementById("reset").onclick = resetFilters;
259 buildLegend();
260
261 let dragging = null;
262 function pt(ev) {
263 const r = svg.getBoundingClientRect();
264 return { x: (ev.clientX - r.left) / r.width * W, y: (ev.clientY - r.top) / r.height * H };
265 }
266 function startDrag(n, ev) {
267 ev.preventDefault();
268 dragging = n; n.fixed = true; kick(0.3);
269 }
270 window.addEventListener("mousemove", ev => {
271 if (!dragging) return;
272 const p = pt(ev); dragging.x = p.x; dragging.y = p.y;
273 });
274 window.addEventListener("mouseup", () => {
275 if (dragging) { dragging.fixed = false; dragging = null; kick(0.1); }
276 });
277 </script>
278 </body>
279 </html>
internal/transport/contract_test.go
Old New
@@ -0,0 +1,58 @@
1 package transport
2
3 import (
4 "bytes"
5 "testing"
6
7 "github.com/a73x/eitri/internal/pb"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/require"
10 "google.golang.org/protobuf/proto"
11 )
12
13 // The wire contract (internal/pb + internal/transport) is the only code shared
14 // across the control/data-plane boundary (invariant R3). These tests pin its
15 // observable behavior: every field of the two top-level envelopes must survive
16 // a WriteMsg/ReadMsg round-trip unchanged. If a field stops being framed, or
17 // the framing is altered incompatibly, proto.Equal fails here.
18
19 func TestAgentMessageReportRoundTrip(t *testing.T) {
20 in := &pb.AgentMessage{Msg: &pb.AgentMessage_Report{Report: &pb.ActualStateReport{
21 Vms: []*pb.ActualVM{
22 {VmId: "vm-1", Power: "on", Phase: "running", Ip: "10.0.0.5", LastError: ""},
23 {VmId: "vm-2", Power: "off", Phase: "stopped"},
24 },
25 Destroyed: []string{"vm-old"},
26 Quarantined: []*pb.QuarantinedVM{{VmId: "vm-q", Name: "q", VmspecJson: []byte(`{"k":1}`), DestroyAtUnix: 1750000000}},
27 Capacity: &pb.Capacity{Vcpus: 16, MemMb: 32768, DiskGb: 500},
28 FenceViolation: true,
29 LastSeenEpoch: 42,
30 }}}
31
32 out := &pb.AgentMessage{}
33 roundTrip(t, in, out)
34 assert.True(t, proto.Equal(in, out), "report did not survive round-trip:\n in=%v\nout=%v", in, out)
35 }
36
37 func TestServerMessageSnapshotRoundTrip(t *testing.T) {
38 in := &pb.ServerMessage{Msg: &pb.ServerMessage_Snapshot{Snapshot: &pb.DesiredStateSnapshot{
39 Epoch: 7,
40 Vms: []*pb.VMDesired{{
41 VmId: "vm-1", Name: "web", ImageUrl: "https://img/x.qcow2", ImageSha256: "abc",
42 CloudInit: "#cloud-config", Vcpus: 4, MemMb: 8192, DiskGb: 40,
43 Persistent: true, PowerState: "on", Tombstoned: false, SshAuthorizedKey: "ssh-ed25519 AAAA",
44 }},
45 }}}
46
47 out := &pb.ServerMessage{}
48 roundTrip(t, in, out)
49 assert.True(t, proto.Equal(in, out), "snapshot did not survive round-trip:\n in=%v\nout=%v", in, out)
50 }
51
52 // roundTrip frames in and reads it back into out.
53 func roundTrip(t *testing.T, in, out proto.Message) {
54 t.Helper()
55 var buf bytes.Buffer
56 require.NoError(t, WriteMsg(&buf, in))
57 require.NoError(t, ReadMsg(&buf, out, DefaultMaxFrame))
58 }
scripts/coverage.sh
Old New
@@ -0,0 +1,80 @@
1 #!/usr/bin/env bash
2 # Per-package coverage ratchet.
3 #
4 # Rather than a single flat number (which lets a well-tested package rot while a
5 # poorly-tested one drags the average), each package has its own floor set a few
6 # points below today's coverage. CI fails if any package drops below its floor;
7 # raise a floor whenever you raise the coverage. Generated code (internal/pb),
8 # thin main packages (cmd/*), and the tag-gated integration tiers are not gated
9 # here — the integration tiers are exercised by `make smoke-go` / `make sandbox`.
10 #
11 # Aspirational targets (not yet enforced): logic/domain packages → 80%,
12 # host-touching effectful packages → 50%. Ratchet the floors toward those.
13 set -euo pipefail
14 cd "$(dirname "$0")/.."
15
16 # package (module-relative) -> minimum acceptable coverage %
17 declare -A FLOOR=(
18 [internal/agent/reconcile]=80
19 [internal/agent/state]=50
20 [internal/agent/seed]=78
21 [internal/agent/ipalloc]=83
22 [internal/agent/imagecache]=66
23 [internal/agent/netenv]=75
24 [internal/agent/overlay]=86
25 [internal/agent/cloudhv]=40
26 [internal/agent/syncclient]=73
27 [internal/server/api]=66
28 [internal/server/api/spec]=81
29 [internal/server/store]=73
30 [internal/server/registry]=95
31 [internal/server/hosttoken]=95
32 [internal/server/hub]=90
33 [internal/server/syncsvc]=72
34 [internal/server/web]=90
35 [internal/transport]=77
36 [internal/shape]=88
37 )
38
39 profile="$(mktemp)"
40 trap 'rm -f "$profile"' EXIT
41
42 # Capture the per-package "coverage: NN.N% of statements" lines.
43 report="$(go test -count=1 -covermode=atomic -coverprofile="$profile" \
44 ./internal/... ./cmd/... 2>&1)"
45
46 fail=0
47 checked=0
48 while IFS= read -r line; do
49 # Lines look like: ok github.com/a73x/eitri/internal/agent/state 0.004s coverage: 54.2% of statements
50 case "$line" in
51 *"coverage:"*"of statements"*) ;;
52 *) continue ;;
53 esac
54 pkg="${line#*github.com/a73x/eitri/}"
55 pkg="${pkg%%[[:space:]]*}"
56 floor="${FLOOR[$pkg]:-}"
57 [ -z "$floor" ] && continue
58 pct="${line##*coverage: }"
59 pct="${pct%%% of statements}"
60 checked=$((checked + 1))
61 if awk "BEGIN{exit !($pct < $floor)}"; then
62 printf ' FAIL %-34s %5s%% < floor %s%%\n' "$pkg" "$pct" "$floor"
63 fail=1
64 else
65 printf ' ok %-34s %5s%% (floor %s%%)\n' "$pkg" "$pct" "$floor"
66 fi
67 done <<<"$report"
68
69 # Guard against a renamed/removed package silently dropping out of the gate.
70 if [ "$checked" -ne "${#FLOOR[@]}" ]; then
71 echo "coverage gate: expected ${#FLOOR[@]} gated packages, saw $checked — a gated package was renamed or removed" >&2
72 echo "$report" | grep -E 'FAIL|cannot|error' >&2 || true
73 exit 1
74 fi
75
76 if [ "$fail" -ne 0 ]; then
77 echo "coverage gate: at least one package fell below its floor" >&2
78 exit 1
79 fi
80 echo "coverage gate: all $checked gated packages meet their floor"