13b9c2e5
feat: architecture governance and the explorable shape diagram
a73x 2026-07-25 09:05
Commit message
.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 => ({ "&": "&", "<": "<", ">": ">" }[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 => ({ "&": "&", "<": "<", ">": ">" }[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" | ||