a73x

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?")
	}
}