Files
Josh Siviour d076a434d4 Feat/e2e targeted suites (#352)
* test(e2e): add targeted suite selector + shadow-mode CI step

select-suites maps a diff to the e2e suites it must run: changed files →
services (manifest path/additional_paths) → consumer closure over
depends_on vs publishes/subscribes → suites whose covers intersect.
Special cases force-all (infra/harness/workflow, ≥3 services, submodule
bumps) or a lone suite dir; empty diffs fall back to default suites. An
AST scan of each suite's harness.Ensure* call graph derives per-test
service coverage and emits an optional -test.run subset (RUN_PATTERN).

Wired into e2e.yml as a non-gating 'Select Suites (shadow)' job that
logs the computed selection to the run summary so predicted vs actual
can be compared before the selector drives SUITES.

* ci(e2e): add dispatch-only per-suite job blocks grouped by env

Restructure the looped e2e flow into env-grouped provision -> matrix
suites -> teardown blocks so each suite surfaces as its own node in the
Actions graph instead of a single green tick:

  single_provision -> single_suite [single,cert,lb,eks] -> single_teardown
  multi_provision  -> multi_suite  [multinode]          -> multi_teardown

Provision resolves the VM/cluster and ships test binaries, emitting WAN
IP + ssh key path as job outputs; each matrix leg runs one suite over
SSH and uploads its own junit/analysis artifacts; teardown (if:always)
reuses the persisted tofu workspace on the same runner to destroy.

workflow_dispatch only -- runs alongside the gating e2e.yml for
validation and posts no commit status.

* test(e2e): add manifest drift lint (fixture-create + NATS subject)

Bead 5 of the targeted-suite-selection plan. Two ratcheted drift guards in
tests/e2e/manifest-lint, wired into make preflight and the spinifex.yml PR job:

- Fixture lint: AST-flags direct resource-create AWS calls
  (c.EC2.RunInstances, c.ELBv2.CreateLoadBalancer, ...) in suite test files,
  steering them through harness.Ensure* fixtures. ~104 pre-existing call sites
  are captured in a checked-in baseline; the lint fails only on NEW creates.
  Intentional create-path tests opt out with an inline // e2e:allow-create.

- Subject lint: extracts NATS subject literals from the natsSub{} registry and
  literal Subscribe/Publish/Request args, cross-referenced wildcard-aware
  against service-interfaces.yaml. Code subjects missing from the manifest fail
  (ratcheted); manifest subjects with no static reference warn only (many are
  produced dynamically). Fixed real drift surfaced on first run: declared the
  five spinifex.cluster.shutdown.* phase subjects under spinifex_daemon.

manifest-check is now CI-enforced alongside manifest-lint (previously
preflight/local-only).

* feat: run-scoped e2e teardown sweep

Tag every Ensure* fixture resource with e2e:run=<run-id> when
SPINIFEX_E2E_RUN_ID is set, and add a teardown.test suite that sweeps
everything carrying that tag at end of run — reclaiming resources leaked
by a crashed suite, an interrupted run, or a persistent cluster.

- harness/sweep.go: tagRunResources (EC2 CreateTags) + tagRunELB (ELBv2
  AddTags) + SweepRunResources in dependency order (LB, instances, NAT,
  snapshots, volumes, images, SGs, subnets, key pairs). Blank run id
  no-ops both tagging and sweeping, so the sweep can never touch an
  untagged or foreign resource.
- harness/fixtures.go: stamp the run tag on each created resource.
- tests/e2e/teardown: thin suite that runs the sweep; skips without a
  cluster or run id, logs the report, never fails the run.
- e2e-suites.yml: build + ship teardown.test, pass the run id into suite
  legs, sweep on the VM in both teardown jobs before tofu destroy.
2026-06-11 21:09:50 +10:00

306 lines
10 KiB
Go

package selectsuites
import (
"os"
"path/filepath"
"reflect"
"runtime"
"sort"
"testing"
manifestcheck "github.com/mulgadc/spinifex/tests/e2e/manifest-check"
)
// testManifest mirrors the essentials of docs/service-interfaces.yaml: the five
// daemon services plus cert/lb/single/ddil/reboot suites and the fixtures
// block. Kept inline so the unit tests do not depend on the real manifest
// drifting.
func testManifest() *Manifest {
return &manifestcheck.Manifest{
Version: 1,
Services: map[string]manifestcheck.Service{
"awsgw": {
Path: "spinifex/services/awsgw",
Publishes: []string{"ec2.*", "elbv2.*", "acm.*", "iam.*"},
DependsOn: []string{"ec2.*", "elbv2.*", "acm.*", "iam.*"},
Subscribes: []string{},
},
"spinifex_daemon": {
Path: "spinifex/services/spinifex",
AdditionalPaths: []string{"spinifex/daemon", "spinifex/handlers/ec2"},
Subscribes: []string{"ec2.RunInstances", "elbv2.CreateLoadBalancer"},
Publishes: []string{"vpc.create", "ebs.mount", "s3.*", "iam.account.created"},
DependsOn: []string{"vpc.*", "ebs.*", "s3.*"},
},
"vpcd": {
Path: "spinifex/vpcd",
Subscribes: []string{"vpc.create", "vpc.delete"},
Publishes: []string{"vpc.port-status"},
DependsOn: []string{},
},
"viperblockd": {
Path: "spinifex/services/viperblockd",
Subscribes: []string{"ebs.mount", "ebs.unmount"},
Publishes: []string{"ebs.mount.response"},
DependsOn: []string{},
},
"predastore": {
Path: "spinifex/services/predastore",
Subscribes: []string{"s3.*"},
Publishes: []string{},
DependsOn: []string{},
},
},
Fixtures: map[string]manifestcheck.Fixture{
"EnsureDefaultVPC": {Services: []string{"vpcd", "spinifex_daemon"}},
"EnsureInstance": {Services: []string{"spinifex_daemon", "viperblockd", "vpcd"}},
"EnsureVolume": {Services: []string{"spinifex_daemon", "viperblockd"}},
},
Suites: map[string]manifestcheck.Suite{
"e2e-cert": {Path: "tests/e2e/cert", Covers: []string{"awsgw", "spinifex_daemon", "vpcd", "predastore"}},
"e2e-lb": {Path: "tests/e2e/lb", Covers: []string{"awsgw", "spinifex_daemon", "vpcd", "viperblockd", "predastore"}},
"e2e-single": {Path: "tests/e2e/single", Covers: []string{"awsgw", "spinifex_daemon", "vpcd", "viperblockd", "predastore"}},
"e2e-ddil": {Path: "tests/e2e/ddil", Covers: []string{"awsgw", "spinifex_daemon", "vpcd", "viperblockd", "predastore"}},
"e2e-reboot": {Path: "tests/e2e/reboot", Covers: []string{"spinifex_daemon", "viperblockd"}},
},
}
}
func selectSuites(t *testing.T, changed []string) Result {
t.Helper()
return Select(testManifest(), DefaultConfig(), changed, nil)
}
func wantSuites(t *testing.T, got Result, want ...string) {
t.Helper()
g := append([]string(nil), got.Suites...)
sort.Strings(g)
w := append([]string(nil), want...)
sort.Strings(w)
if !reflect.DeepEqual(g, w) {
t.Fatalf("suites = %v, want %v (reason: %s)", g, w, got.Reason)
}
}
func TestSelect_EmptyDiff_Default(t *testing.T) {
got := selectSuites(t, nil)
wantSuites(t, got, "e2e-cert", "e2e-lb")
}
func TestSelect_VPCDChange_ClosureToAWSGW(t *testing.T) {
// vpcd → spinifex_daemon (depends_on vpc.*) → awsgw (depends_on iam.* of
// daemon's iam.account.created). reboot does not cover awsgw/vpcd but does
// cover spinifex_daemon, so it is included; every suite covers the daemon.
got := selectSuites(t, []string{"spinifex/vpcd/topology.go"})
wantSuites(t, got, "e2e-cert", "e2e-lb", "e2e-single", "e2e-ddil", "e2e-reboot")
}
func TestSelect_PredastoreChange_DiscriminatesReboot(t *testing.T) {
// predastore publishes nothing, but spinifex_daemon depends_on s3.* which
// predastore subscribes → daemon + awsgw covered. reboot covers neither
// predastore nor awsgw, only daemon/viperblockd → still matches via daemon.
// Use a service nobody depends on to truly discriminate: see vpcd-isolated.
got := selectSuites(t, []string{"spinifex/services/predastore/adapter.go"})
wantSuites(t, got, "e2e-cert", "e2e-lb", "e2e-single", "e2e-ddil", "e2e-reboot")
}
func TestSelect_AdditionalPaths(t *testing.T) {
// handlers/ec2 is an additional_path of spinifex_daemon.
got := selectSuites(t, []string{"spinifex/handlers/ec2/runinstances.go"})
if len(got.Suites) == 0 || got.AllSuites {
t.Fatalf("expected service-mapped selection, got %+v", got)
}
}
func TestSelect_HarnessChange_ForcesAll(t *testing.T) {
got := selectSuites(t, []string{"tests/e2e/harness/fixtures.go"})
if !got.AllSuites {
t.Fatalf("harness change must force all; got %+v", got)
}
wantSuites(t, got, "e2e-cert", "e2e-lb", "e2e-single", "e2e-ddil", "e2e-reboot")
}
func TestSelect_WorkflowChange_ForcesAll(t *testing.T) {
got := selectSuites(t, []string{".github/workflows/e2e.yml"})
if !got.AllSuites {
t.Fatalf("workflow change must force all; got %+v", got)
}
}
func TestSelect_NatsInfra_ForcesAll(t *testing.T) {
got := selectSuites(t, []string{"spinifex/services/nats/cluster.go"})
if !got.AllSuites {
t.Fatalf("nats change must force all; got %+v", got)
}
}
func TestSelect_ManyServices_ForcesAll(t *testing.T) {
got := selectSuites(t, []string{
"spinifex/vpcd/x.go",
"spinifex/services/viperblockd/y.go",
"spinifex/services/awsgw/z.go",
})
if !got.AllSuites {
t.Fatalf("≥3 services must force all; got %+v", got)
}
}
func TestSelect_LoneSuiteDir(t *testing.T) {
got := selectSuites(t, []string{"tests/e2e/cert/cert_test.go"})
wantSuites(t, got, "e2e-cert")
if got.AllSuites {
t.Fatalf("lone suite dir must not force all")
}
}
func TestSelect_SuiteDirPlusSource_NotLone(t *testing.T) {
// A suite test edit alongside a service edit is not a lone-suite diff; it
// resolves through the service map (and may add suites).
got := selectSuites(t, []string{"tests/e2e/cert/cert_test.go", "spinifex/vpcd/x.go"})
if len(got.Suites) < 2 {
t.Fatalf("expected service-mapped multi-suite, got %+v", got)
}
}
func TestSelect_Submodule_ForcesAll(t *testing.T) {
got := selectSuites(t, []string{"viperblock"})
if !got.AllSuites {
t.Fatalf("submodule pointer change must force all; got %+v", got)
}
}
func TestSelect_UnmappedDocOnly_Default(t *testing.T) {
// A docs-only change maps to no service and no suite dir → default suites.
got := selectSuites(t, []string{"docs/DESIGN.md"})
wantSuites(t, got, "e2e-cert", "e2e-lb")
}
func TestSubjectMatch(t *testing.T) {
cases := []struct {
a, b string
want bool
}{
{"vpc.*", "vpc.port-status", true},
{"vpc.create", "vpc.*", true},
{"vpc.*", "vpc.*", true},
{"vpc.*", "vpcd.create", false}, // prefix must hit a segment boundary
{"ebs.*", "ebs.mount.response", true},
{"s3.*", "s3.*", true},
{"ec2.RunInstances", "ec2.RunInstances", true},
{"ec2.RunInstances", "ec2.Terminate", false},
{"vpc.create.*", "vpc.*", true},
}
for _, c := range cases {
if got := subjectMatch(c.a, c.b); got != c.want {
t.Errorf("subjectMatch(%q,%q) = %v, want %v", c.a, c.b, got, c.want)
}
}
}
// --- AST scan -------------------------------------------------------------
func TestScanSuites_PerTestCoverage(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "tests", "e2e", "faux")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
src := `//go:build e2e
package faux
import "testing"
func helperVPC(t *testing.T) string { return harness.EnsureDefaultVPC(t, nil) }
func TestVPCOnly(t *testing.T) {
_ = helperVPC(t) // transitive Ensure via local helper
}
func TestInstance(t *testing.T) {
_ = harness.EnsureInstance(t, nil, spec)
}
func TestReadOnly(t *testing.T) {
// no Ensure* calls → unknown coverage, always kept
}
`
if err := os.WriteFile(filepath.Join(dir, "faux_test.go"), []byte(src), 0o600); err != nil {
t.Fatal(err)
}
m := testManifest()
m.Suites = map[string]manifestcheck.Suite{
"e2e-faux": {Path: "tests/e2e/faux", Covers: []string{"vpcd"}},
}
cov, err := ScanSuites(root, m)
if err != nil {
t.Fatalf("ScanSuites: %v", err)
}
faux, ok := cov["e2e-faux"]
if !ok {
t.Fatalf("no coverage for e2e-faux: %+v", cov)
}
// TestVPCOnly reaches EnsureDefaultVPC via helperVPC → {vpcd, spinifex_daemon}.
if !reflect.DeepEqual(faux.Tests["TestVPCOnly"], []string{"spinifex_daemon", "vpcd"}) {
t.Errorf("TestVPCOnly services = %v", faux.Tests["TestVPCOnly"])
}
// TestInstance → EnsureInstance → {spinifex_daemon, viperblockd, vpcd}.
if !reflect.DeepEqual(faux.Tests["TestInstance"], []string{"spinifex_daemon", "viperblockd", "vpcd"}) {
t.Errorf("TestInstance services = %v", faux.Tests["TestInstance"])
}
// TestReadOnly has no fixtures.
if len(faux.Tests["TestReadOnly"]) != 0 {
t.Errorf("TestReadOnly should have no services, got %v", faux.Tests["TestReadOnly"])
}
}
func TestRunPatterns_StrictSubset(t *testing.T) {
perTest := map[string]SuiteCoverage{
"e2e-single": {Tests: map[string][]string{
"TestVPCCRUD": {"vpcd", "spinifex_daemon"},
"TestVolume": {"viperblockd", "spinifex_daemon"},
"TestReadOnly": {}, // always kept
"TestSnapshotBak": {"viperblockd", "spinifex_daemon", "predastore"},
}},
}
// covered excludes spinifex_daemon: only a vpcd-confined closure narrows,
// since almost every test transitively touches the daemon.
covered := map[string]bool{"vpcd": true}
got := runPatterns(perTest, []string{"e2e-single"}, covered)
// Keeps TestVPCCRUD (vpcd) + TestReadOnly (unknown). Drops Volume/Snapshot.
want := "^(TestReadOnly|TestVPCCRUD)$"
if got["e2e-single"] != want {
t.Fatalf("RUN_PATTERN = %q, want %q", got["e2e-single"], want)
}
}
func TestRunPatterns_NoNarrowingWhenAllMatch(t *testing.T) {
perTest := map[string]SuiteCoverage{
"e2e-single": {Tests: map[string][]string{
"TestA": {"vpcd"},
"TestB": {"vpcd"},
}},
}
covered := map[string]bool{"vpcd": true}
got := runPatterns(perTest, []string{"e2e-single"}, covered)
if _, ok := got["e2e-single"]; ok {
t.Fatalf("expected no pattern when all tests match, got %v", got)
}
}
// Guard: the real manifest loads and Select runs against it without panicking.
func TestSelect_RealManifest_Smoke(t *testing.T) {
_, file, _, _ := runtime.Caller(0)
root := filepath.Join(filepath.Dir(file), "..", "..", "..")
m, err := manifestcheck.Load(filepath.Join(root, "docs", "service-interfaces.yaml"))
if err != nil {
t.Fatalf("load real manifest: %v", err)
}
res := Select(m, DefaultConfig(), []string{"spinifex/vpcd/topology.go"}, nil)
if len(res.Suites) == 0 {
t.Fatalf("real manifest vpcd change selected no suites")
}
}