internal/arch/arch_test.go
Ref: Size: 19.8 KiB History
package arch
import (
"os/exec"
"sort"
"strings"
"testing"
"github.com/a73x/eitri/internal/shape"
)
// module is the import-path prefix shared by every package in this repo.
const module = "github.com/a73x/eitri"
// directImports returns, for every package under internal/... and cmd/..., the
// list of packages it imports directly (including stdlib). Edges are taken from
// `go list`, so this reflects the real, compiled dependency graph rather than a
// hand-maintained description that can drift.
//
// The query uses absolute module patterns (not ./...) so it is independent of
// the test's working directory.
//
// NOTE: because the dependency graph is gathered by shelling out rather than
// from this package's own source files, Go's test cache cannot tell when an
// edge elsewhere in the module changes. Always run these tests with -count=1
// (the `make arch` target and CI do); a plain `go test ./...` may serve a stale
// cached result.
func directImports(t *testing.T) map[string][]string {
t.Helper()
out, err := exec.Command("go", "list",
"-f", `{{.ImportPath}} {{join .Imports " "}}`,
module+"/internal/...", module+"/cmd/...").CombinedOutput()
if err != nil {
t.Fatalf("go list failed: %v\n%s", err, out)
}
graph := map[string][]string{}
for line := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
graph[fields[0]] = fields[1:]
}
return graph
}
// internalImports keeps only edges to other packages in this module — the ones
// that express our own layering. Stdlib and third-party edges are dropped.
func internalImports(t *testing.T) map[string][]string {
t.Helper()
g := directImports(t)
internal := map[string][]string{}
for pkg, deps := range g {
for _, d := range deps {
if strings.HasPrefix(d, module+"/") {
internal[pkg] = append(internal[pkg], d)
}
}
}
return internal
}
// transitiveDeps returns every internal package reachable from pkg, following
// internal edges. Used for rules that must hold through the whole dependency
// chain (e.g. "no server package may reach an agent package, however indirectly").
func transitiveDeps(graph map[string][]string, pkg string) map[string]bool {
seen := map[string]bool{}
var walk func(string)
walk = func(p string) {
for _, d := range graph[p] {
if !seen[d] {
seen[d] = true
walk(d)
}
}
}
walk(pkg)
return seen
}
// short trims the module prefix for readable failure messages.
func short(pkg string) string { return strings.TrimPrefix(pkg, module+"/") }
func has(pkg, sub string) bool { return strings.Contains(short(pkg), sub) }
// R1: the control plane (internal/server/*) and the data plane (internal/agent/*)
// are independently deployable binaries on different hosts. They must never
// import each other — even transitively. The only code they may share is the
// wire contract (internal/pb, internal/transport); see R3.
func TestControlAndDataPlaneAreDisjoint(t *testing.T) {
g := internalImports(t)
for pkg := range g {
deps := transitiveDeps(g, pkg)
switch {
case has(pkg, "internal/server/"):
for d := range deps {
if has(d, "internal/agent/") {
t.Errorf("control-plane package %s must not import data-plane package %s", short(pkg), short(d))
}
}
case has(pkg, "internal/agent/"):
for d := range deps {
if has(d, "internal/server/") {
t.Errorf("data-plane package %s must not import control-plane package %s", short(pkg), short(d))
}
}
}
}
}
// R9: every wire-plane package is a leaf — it may import no other internal
// package. The wire plane is the only code shared across the control and
// data planes, so a single heavy dependency there (the store, the transport
// stack) would leak into both binaries at once. Generalizes R3's hardcoded
// pb/transport pair: membership comes from the shape classifier, so a new
// wire package inherits the rule the moment it's classified.
func TestWirePlaneIsLeaf(t *testing.T) {
// directImports (not internalImports) so leaf packages with zero internal
// deps still show up as keys — internalImports only carries packages that
// already have at least one internal edge, which would silently drop the
// very packages this rule exists to sweep.
g := directImports(t)
swept := 0
for pkg, deps := range g {
rel := strings.TrimPrefix(pkg, module+"/")
if shape.Classify(rel) != shape.PlaneWire {
continue
}
swept++
for _, d := range deps {
if strings.HasPrefix(d, module+"/") {
t.Errorf("wire package %s must not import any internal package, but imports %s", short(pkg), short(d))
}
}
}
if swept < 9 {
t.Errorf("wire-plane sweep saw only %d packages — classifier drift? (expect pb, transport, joinblob, cloudinit, guest, names, random, version, relmanifest)", swept)
}
}
// R15: nothing in eitri serialises a protobuf message by NAME. Protobuf's
// binary encoding carries field numbers only, which is what makes the message
// and field names in proto/eitri/v1/sync.proto free to be renamed for clarity
// without breaking an agent that predates the rename (the field-number lock in
// internal/transport guards the numbers themselves). protojson breaks that: it
// encodes names, so the moment a JSON dump, debug endpoint or MCP result is
// rendered through it, those names become a contract — and the NEXT rename
// breaks whatever already persisted them, silently and after the fact.
//
// The JSON that eitri does speak is internal/server/api/types, which is a
// hand-written contract with its own golden files and deprecation discipline.
// Route new JSON through there, not through the wire messages.
func TestNoProtobufJSONSerialization(t *testing.T) {
g := directImports(t)
const protojson = "google.golang.org/protobuf/encoding/protojson"
if len(g) == 0 {
t.Fatal("import graph is empty — go list returned nothing to sweep")
}
for pkg, deps := range g {
for _, d := range deps {
if d == protojson {
t.Errorf("package %s imports %s — protobuf names are not a contract; "+
"serialise through internal/server/api/types instead", short(pkg), d)
}
}
}
}
// R10: modernc.org/sqlite is imported by internal/server/store and nowhere
// else. The store owns schema application and the single-connection write
// serialization; a second driver import would bypass both silently. The
// database/sql package itself stays free (sql.ErrNoRows is an idiomatic
// sentinel for store callers).
func TestSQLiteDriverIsStoresAlone(t *testing.T) {
g := directImports(t)
const driver = "modernc.org/sqlite"
const owner = module + "/internal/server/store"
found := false
for pkg, deps := range g {
for _, d := range deps {
if d != driver {
continue
}
found = true
if pkg != owner {
t.Errorf("package %s imports %s directly, but only %s may", short(pkg), driver, short(owner))
}
}
}
if !found {
t.Errorf("sweep found no importer of %s at all — did the driver move or get vendored differently?", driver)
}
}
// R4: domain/state packages hold pure logic and must not depend on the
// transport stack (HTTP, QUIC) or its serialization concerns. This keeps them
// trivially testable and serialization-agnostic.
//
// internal/server/store is a domain package that legitimately imports
// internal/transport today for cert/fingerprint helpers, so it is asserted
// separately: it may touch transport but still must not reach for net/http or
// QUIC directly.
func TestDomainDoesNotImportTransportStack(t *testing.T) {
g := directImports(t)
pureDomain := []string{
module + "/internal/agent/state",
module + "/internal/agent/seed",
module + "/internal/agent/ipalloc",
module + "/internal/server/registry",
}
forbiddenForPure := []string{
"net/http",
"github.com/quic-go/quic-go",
module + "/internal/transport",
}
for _, pkg := range pureDomain {
assertNotImported(t, g, pkg, forbiddenForPure)
}
// store may use transport (documented), but not the live network stack.
assertNotImported(t, g, module+"/internal/server/store", []string{
"net/http",
"github.com/quic-go/quic-go",
})
}
// R2: the server is a pure control plane. It expresses intent as desired state
// and never actuates VMs itself, so no package under internal/server may reach
// os/exec — directly OR through an internal wrapper package (checking only
// direct imports would let internal/util/somewrapper smuggle a shell-out in).
func TestServerNeverShellsOut(t *testing.T) {
g := directImports(t)
for pkg, offenders := range execViolations(g, module, "internal/server/", nil) {
for _, o := range offenders {
t.Errorf("control-plane package %s reaches os/exec via %s — the server never shells out", short(pkg), short(o))
}
}
}
// R6: all external process execution in the data plane funnels through
// agent/exec.Runner, which keeps the host-touching packages mockable and
// auditable. Two roles are exempt. Provisioner packages launch the long-lived
// VMM process directly (exec.Command) rather than through the one-shot Runner
// — starting processes on the host is what a provisioner IS, so each backend
// is sanctioned for the same reason rather than re-argued. The composition
// root constructs the concrete Runner it injects. Every other agent package
// must use Runner and must not reach os/exec — directly or through an internal
// wrapper (reaching it via a sanctioned package is fine).
func TestOnlyProvisionersAndRootImportOsExecInDataPlane(t *testing.T) {
g := directImports(t)
allowed := map[string]bool{
module + "/internal/agent/cloudhv": true, // provisioner: spawns cloud-hypervisor
module + "/internal/agent/vfkit": true, // provisioner: spawns vfkit
module + "/internal/agent/run": true, // composition root: builds hostRunner
}
for pkg, offenders := range execViolations(g, module, "internal/agent/", allowed) {
for _, o := range offenders {
t.Errorf("data-plane package %s reaches os/exec via %s — use agent/exec.Runner instead", short(pkg), short(o))
}
}
}
// R7: external process execution anywhere in internal/ is confined to a
// sanctioned allowlist — cloudhv and vfkit (provisioners: spawning the VMM
// directly is what a provisioner is, R6's narrower story), agent/run (the agent
// composition root, which builds the Runner it injects into every other
// package), cli (interactive ssh must be the real OpenSSH client), shape
// (architecture tooling that shells out to `go list`, the same introspection
// internal/arch's own tests do), and smoke (the deploy boot-gate harness, an
// external test rig that shells out to ssh, pkill, and `go tool covdata`).
// R2/R6 tell the per-plane stories; R7 is the whole-tree backstop that makes a
// new exec site anywhere an explicit, reviewed decision.
func TestExecIsConfinedToSanctionedPackages(t *testing.T) {
g := directImports(t)
allowed := map[string]bool{
module + "/internal/agent/cloudhv": true, // provisioner: spawns the VMM directly (R6's story)
module + "/internal/agent/vfkit": true, // provisioner: the same sanction, one platform over
module + "/internal/agent/run": true, // agent composition root: builds the injected Runner
module + "/internal/cli": true, // interactive sessions must be the real OpenSSH client
module + "/internal/shape": true, // architecture tooling: shells `go list -json` to build the module graph — the same shell-out internal/arch's own tests make
module + "/internal/smoke": true, // deploy boot-gate harness: shells out to ssh, pkill, and `go tool covdata` against the live fleet
}
for pkg, offenders := range execViolations(g, module, "internal/", allowed) {
for _, o := range offenders {
t.Errorf("package %s reaches os/exec via %s — exec anywhere in internal/ requires an explicit allowlist entry (see R7)", short(pkg), short(o))
}
}
}
// R8: the client CLI is a leaf of its own binary. internal/cli carries the
// exec sanction (R7) and touches the user's machine (~/.ssh); letting a
// server, agent, or tooling package import it would smuggle both across a
// plane boundary. Only cmd/eitri may import it.
func TestClientCLIIsOnlyImportedByItsBinary(t *testing.T) {
g := internalImports(t)
target := module + "/internal/cli"
allowedImporter := module + "/cmd/eitri"
for pkg, deps := range g {
if pkg == target || pkg == allowedImporter {
continue
}
for _, d := range deps {
if d == target {
t.Errorf("package %s must not import %s — only %s may", short(pkg), short(target), short(allowedImporter))
}
}
}
}
// R11: the API contract is consumed through the client. internal/server/api/types
// is the wire contract, but no package outside internal/server/* may import it —
// consumers get the wire structs via internal/server/api/client's re-exported
// aliases (client.Host and friends), so the client is the only door. This rule
// stops a fourth hand-rolled client at the import graph: the first three
// (mcpserver/eitriapi.go, cmd/eitri-smoke/client.go, internal/cli's raw HTTP)
// each began as "just import the types and fmt.Sprintf the paths", and each
// would have tripped this exact sweep on its first commit.
func TestAPIContractIsConsumedThroughTheClient(t *testing.T) {
g := internalImports(t)
target := module + "/internal/server/api/types"
imported := false
for pkg, deps := range g {
for _, d := range deps {
if d != target {
continue
}
imported = true
if !has(pkg, "internal/server/") {
t.Errorf("package %s must not import %s — consume the API through internal/server/api/client instead", short(pkg), short(target))
}
}
}
if !imported {
// A rename of the types package would silently pass every sweep; guard it.
t.Errorf("sweep found no importer of %s at all — did the contract package move?", short(target))
}
}
// R12: internal/server/api/types is a leaf — stdlib imports only. The contract
// is consumed by the spec generator, the client, and the handlers; a single
// internal import would drag server internals into every consumer at once and
// break the reflection-based OpenAPI generator's "types package = the whole
// wire" guarantee (walking the package would surface types that are not wire
// contract at all). R9 tells the same story for the wire plane; this is the
// API contract's own copy of it.
func TestAPITypesIsALeaf(t *testing.T) {
g := directImports(t)
target := module + "/internal/server/api/types"
deps, ok := g[target]
if !ok {
// A typo or rename would silently pass; guard it.
t.Fatalf("package %s not found in import graph (renamed or removed?)", short(target))
}
for _, d := range deps {
if strings.HasPrefix(d, module+"/") {
t.Errorf("contract package %s must import only the standard library, but imports %s", short(target), short(d))
}
}
}
// R13: the issuer/relying-party split is Eitri's OIDC security boundary.
// eitri-server is a pure relying party; the bundled issuer lives in the
// separate cmd/eitri-oidc binary over internal/oidcprovider. Two edges keep
// the halves apart:
//
// (a) internal/oidcprovider may be imported only by cmd/eitri-oidc. A server
// (or any other) import would relink the issuer into the relying party and
// silently rebuild the embedded-IdP coupling this split exists to prevent.
// Test files anywhere may still import it as an in-process IdP
// (server/api's auth_test, internal/smoke's login_test); those edges are
// test-only and never enter the production graph go list reports here.
// (b) the go-oidc verifier module belongs to internal/server/api, the relying
// party, alone. It is the client half of the protocol — anywhere else it
// appears, a second relying party is being hand-rolled. (oidcprovider
// verifies against go-oidc too, but only in its round-trip tests, so that
// edge is likewise absent from the production graph.)
//
// Mirrors R8 (a package that is a leaf of its own binary) and R10 (a module
// pinned to one owner), applied to the two sides of the OIDC boundary. See the
// issuer/relying-party rationale in the console multi-tenancy design (spec §2).
func TestIssuerAndRelyingPartyAreSeparate(t *testing.T) {
// (a) internal/oidcprovider is a leaf of the eitri-oidc binary.
g := internalImports(t)
const issuer = module + "/internal/oidcprovider"
const issuerBinary = module + "/cmd/eitri-oidc"
importedBy := false
for pkg, deps := range g {
if pkg == issuer {
continue
}
for _, d := range deps {
if d != issuer {
continue
}
importedBy = true
if pkg != issuerBinary {
t.Errorf("package %s must not import %s — only %s may (the issuer must never link into the relying party)", short(pkg), short(issuer), short(issuerBinary))
}
}
}
if !importedBy {
// A rename of the issuer package would silently pass; guard it.
t.Errorf("sweep found no importer of %s at all — did the issuer package move?", short(issuer))
}
// (b) the go-oidc verifier is the relying party's alone. The imported path
// is the /oidc subpackage, so match the module prefix (mirrors R10's
// single-owner pin, prefix-matched because the module ships one package).
gd := directImports(t)
const goOIDC = "github.com/coreos/go-oidc/v3"
const relyingParty = module + "/internal/server/api"
found := false
for pkg, deps := range gd {
for _, d := range deps {
if !strings.HasPrefix(d, goOIDC) {
continue
}
found = true
if pkg != relyingParty {
t.Errorf("package %s imports %s, but only the relying party %s may", short(pkg), d, short(relyingParty))
}
}
}
if !found {
t.Errorf("sweep found no importer of %s at all — did the module move or the relying party change?", goOIDC)
}
}
// assertNotImported fails if pkg directly imports any path in forbidden.
func assertNotImported(t *testing.T, g map[string][]string, pkg string, forbidden []string) {
t.Helper()
deps := g[pkg]
if deps == nil {
// A typo in a package path would silently pass every rule; guard it.
t.Fatalf("package %s not found in import graph (renamed or removed?)", short(pkg))
}
bad := map[string]bool{}
for _, f := range forbidden {
bad[f] = true
}
var hits []string
for _, d := range deps {
if bad[d] {
hits = append(hits, d)
}
}
if len(hits) > 0 {
sort.Strings(hits)
t.Errorf("domain package %s must not import: %s", short(pkg), strings.Join(hits, ", "))
}
}
// R14: main packages are wiring, not logic. A cmd/* package may import
// packages from this module plus a tiny stdlib allowlist — enough to print a
// version, dispatch to an internal Run, and exit non-zero. Every other import
// (net/http, encoding/json, flag, crypto, third-party modules) is evidence of
// logic living in a package that the coverage gate cannot see (scripts/
// coverage.sh floors internal/, not cmd/) — move it behind a tested
// `Run(...) error` in an internal package instead.
//
// r14Grandfathered names the mains that predate the rule and still carry
// logic. The list is a ratchet: entries may only be REMOVED (as their logic
// is extracted); never add one, and never grow an entry's import surface.
func TestMainPackagesAreWiringOnly(t *testing.T) {
allowedStd := map[string]bool{
"errors": true, // errors.Is on sentinel errors from the internal Run
"fmt": true,
"log": true,
"log/slog": true,
"os": true,
}
r14Grandfathered := map[string]bool{
// Empty: every main is now wiring over a tested internal Run. The map
// stays as the ratchet mechanism — a new main has nowhere to hide.
}
g := directImports(t)
seen := 0
for pkg, deps := range g {
if !strings.HasPrefix(pkg, module+"/cmd/") {
continue
}
seen++
if r14Grandfathered[pkg] {
continue
}
var bad []string
for _, d := range deps {
if strings.HasPrefix(d, module+"/") || allowedStd[d] {
continue
}
bad = append(bad, d)
}
if len(bad) > 0 {
sort.Strings(bad)
t.Errorf("main package %s imports %s — mains are wiring only (R14): move the logic behind a tested Run() in an internal package", short(pkg), strings.Join(bad, ", "))
}
}
if seen == 0 {
t.Error("sweep found no cmd/ packages at all — did the go list pattern break?")
}
}