feat(e2e): add service-interfaces.yaml manifest + validator

Source of truth for which spinifex services produce/consume which NATS
subjects, and which e2e suites cover which services. Consumed downstream
by the targeted suite selector (Bead 4) to derive subset E2E runs from a
PR diff so single-service changes stop paying the full 25 min suite cost.

- docs/service-interfaces.yaml: 7 services, 4 active suites (cert, lb,
  single, ddil). Subscribes / publishes / depends_on filled from grep
  of NATS subjects in source. spinifexui marked covers_no_suite so
  UI-only diffs skip e2e entirely. Future suites (multinode, reboot,
  tofu, baremetal) commented in until Go ports land.
- tests/e2e/manifest-check: schema + cross-reference validator (path
  existence, subject regex, duplicate detection, suite covers resolves
  to a real service, fixtures references resolve). 8 unit tests cover
  the failure modes plus a load-and-validate on the real manifest.
- Makefile: manifest-check target, hooked into preflight.
- docs/DESIGN.md: link to the manifest under "NATS Messaging".

mulga-siv-116. See docs/development/improvements/e2e-targeted-suite-selection.md.
This commit is contained in:
joshsiv-mulga
2026-05-19 23:26:42 +10:00
parent b348003922
commit 6d38fcad5c
6 changed files with 669 additions and 1 deletions
+8 -1
View File
@@ -106,9 +106,16 @@ go_run:
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
# Use this before committing to catch CI failures locally.
preflight:
@$(MAKE) --no-print-directory QUIET=1 lint govulncheck test-cover diff-coverage test-race
@$(MAKE) --no-print-directory QUIET=1 manifest-check lint govulncheck test-cover diff-coverage test-race
@echo -e "\n ✅ Preflight passed — safe to commit."
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
# of services/suites/fixtures + on-disk path existence. Subject content
# vs source is enforced separately in Bead 5 drift lint.
manifest-check:
@echo -e "\n....Checking service-interfaces.yaml...."
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
+7
View File
@@ -84,6 +84,13 @@ The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and de
### 4. NATS Messaging
The full service-to-subject map — which service subscribes to what, what
each one publishes downstream, what each depends on — is captured in
[`service-interfaces.yaml`](./service-interfaces.yaml). It is the source
of truth for targeted e2e suite selection (see
`docs/development/improvements/e2e-targeted-suite-selection.md`) and is
validated by `make manifest-check`.
The gateway communicates with daemons via NATS request/response. Most calls go through `utils.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
```go
+274
View File
@@ -0,0 +1,274 @@
# Service interface manifest.
#
# Source of truth for "which services produce/consume which NATS subjects"
# and "which e2e suites cover which services". Consumed by
# tests/e2e/select-suites (Bead 4) to derive targeted suite selection from
# a PR diff.
#
# See docs/development/improvements/e2e-targeted-suite-selection.md.
#
# Editing rules
# -------------
# - subscribes / publishes: subjects this service receives / sends. Both
# lists use the literal subject string, or a "<prefix>.*" wildcard for
# pattern subscriptions (e.g. "ebs.snapshot.*").
# - depends_on: subjects this service requires from peers. Used by the
# selector to compute "if X changes, which suites might break".
# - Subjects must match what is actually referenced in source. The lint
# in Bead 5 greps `"(ec2|vpc|ebs|s3|iam|elbv2|spinifex)\.[a-zA-Z._-]+"`
# and fails if any subject is absent from this manifest.
version: 1
services:
awsgw:
path: spinifex/services/awsgw
# TLS frontend, SigV4 auth. Pure router: HTTP → NATS request/response.
# No subscribes; only publishes (via NATSRequest).
subscribes: []
publishes:
- "ec2.*"
- "elbv2.*"
- "iam.*"
depends_on:
- "ec2.*"
- "elbv2.*"
- "iam.*"
spinifex_daemon:
path: spinifex/services/spinifex
# Orchestrator. Owns most ec2.* + all elbv2.* + iam event publication.
# Translates VPC / EBS / S3 operations into vpc.* / ebs.* / s3.* and
# forwards to vpcd / viperblockd / predastore.
#
# Source layout: spinifex/services/spinifex (entry), spinifex/daemon
# (subscribe registration), spinifex/handlers/{ec2,elbv2,iam} (per-
# subject handler bodies). Selector treats all four paths as belonging
# to this service via the `additional_paths` field.
additional_paths:
- spinifex/daemon
- spinifex/handlers/ec2
- spinifex/handlers/elbv2
- spinifex/handlers/iam
subscribes:
# EC2 instance lifecycle.
- "ec2.RunInstances"
- "ec2.DescribeInstances"
- "ec2.DescribeStoppedInstances"
- "ec2.DescribeTerminatedInstances"
- "ec2.DescribeInstanceAttribute"
- "ec2.DescribeInstanceStatus"
- "ec2.DescribeInstanceTypes"
- "ec2.ModifyInstanceAttribute"
- "ec2.ModifyVolume"
- "ec2.start"
- "ec2.terminate"
- "ec2.launch"
- "ec2.cmd.*"
# EC2 VPC / network ops handled by daemon before delegation to vpcd.
- "ec2.CreateVpc"
- "ec2.DescribeVpcs"
- "ec2.CreateSubnet"
- "ec2.CreateInternetGateway"
- "ec2.CreateKeyPair"
- "ec2.CreateImage"
- "ec2.DescribePlacementGroups"
# Cluster coordination subjects.
- "ec2.ReserveClusterNode"
- "ec2.FinalizeClusterInstances"
- "spinifex.node.status"
- "spinifex.node.vms"
- "spinifex.nodes.discover"
- "spinifex.cluster.shutdown.progress"
- "spinifex.storage.config"
# ELBv2 control plane (handlers/elbv2/service_nats.go topic map).
- "elbv2.CreateLoadBalancer"
- "elbv2.DeleteLoadBalancer"
- "elbv2.DescribeLoadBalancers"
- "elbv2.CreateTargetGroup"
- "elbv2.DeleteTargetGroup"
- "elbv2.DescribeTargetGroups"
- "elbv2.RegisterTargets"
- "elbv2.DeregisterTargets"
- "elbv2.DescribeTargetHealth"
- "elbv2.CreateListener"
- "elbv2.DeleteListener"
- "elbv2.DescribeListeners"
publishes:
# → vpcd
- "vpc.create"
- "vpc.delete"
- "vpc.create-subnet"
- "vpc.delete-subnet"
- "vpc.create-sg"
- "vpc.delete-sg"
- "vpc.update-sg"
- "vpc.create-port"
- "vpc.delete-port"
- "vpc.update-port-sgs"
- "vpc.igw-attach"
- "vpc.igw-detach"
- "vpc.add-nat-gateway"
- "vpc.delete-nat-gateway"
- "vpc.add-nat"
- "vpc.delete-nat"
- "vpc.dhcp.acquire"
- "vpc.dhcp.release"
# → viperblockd
- "ebs.mount"
- "ebs.unmount"
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
# → predastore (S3 / AMI / keypair store)
- "s3.*"
# IAM lifecycle event
- "iam.account.created"
depends_on:
- "vpc.*"
- "ebs.*"
- "s3.*"
vpcd:
path: spinifex/services/vpcd
# OVN-backed VPC / subnet / SG / NAT / IGW data plane.
subscribes:
- "vpc.create"
- "vpc.delete"
- "vpc.create-subnet"
- "vpc.delete-subnet"
- "vpc.create-sg"
- "vpc.delete-sg"
- "vpc.update-sg"
- "vpc.create-port"
- "vpc.delete-port"
- "vpc.update-port-sgs"
- "vpc.igw-attach"
- "vpc.igw-detach"
- "vpc.add-nat-gateway"
- "vpc.delete-nat-gateway"
- "vpc.add-nat"
- "vpc.delete-nat"
- "vpc.dhcp.acquire"
- "vpc.dhcp.release"
publishes:
- "vpc.port-status"
- "vpc.dhcp.lease-expired"
depends_on: []
viperblockd:
path: spinifex/services/viperblockd
# EBS data plane. Backed by the viperblock storage engine in the
# mulga/viperblock submodule. Submodule pointer bump → selector forces
# "run all" (contract changes can be invisible at the spinifex source
# level).
subscribes:
- "ebs.mount"
- "ebs.unmount"
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
publishes:
- "ebs.mount.response"
- "ebs.unmount.response"
depends_on: []
predastore:
path: spinifex/services/predastore
# S3 / AMI / keypair storage. External submodule under mulga/predastore;
# this dir contains only the spinifex-side adapter. Submodule pointer
# bump → selector forces "run all".
subscribes:
- "s3.*"
publishes: []
depends_on: []
nats:
path: spinifex/services/nats
# NATS broker bootstrap / cluster config. Not a NATS pub/sub
# participant. Any change here is infra-path; selector forces
# "run all".
subscribes: []
publishes: []
depends_on: []
spinifexui:
path: spinifex/services/spinifexui
# Web UI. Talks to awsgw over HTTP, never NATS. Diffs limited to this
# dir do not need any e2e suite to run.
subscribes: []
publishes: []
depends_on: []
covers_no_suite: true
# Fixture → service mapping. Populated by Bead 2 (e2e-fixtures-harness)
# once tests/e2e/harness/fixtures.go exists. Until then, the selector
# falls back to suite-level `covers` only.
fixtures: {}
suites:
e2e-cert:
path: tests/e2e/cert
covers: [awsgw, spinifex_daemon, vpcd, predastore]
e2e-lb:
path: tests/e2e/lb
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Exercises full ELBv2 control plane (handlers/elbv2) + data plane
# (LB agent on the system VM). ELBv2 is part of spinifex_daemon in
# this manifest; revisit if it splits into a standalone service.
e2e-single:
path: tests/e2e/single
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Full single-node lifecycle: VPC, AMI, keypair, instance, volume,
# snapshot, IAM. Currently runs all phases sequentially under one
# `TestSingleNode`. Bead 3 (single-fixture-migration) splits this
# into per-test fixture-driven coverage so the selector can pick
# subtests, not just whole suites.
e2e-ddil:
path: tests/e2e/ddil
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# DDIL = degraded / disconnected / intermittent / low-bandwidth.
# Exercises the full daemon stack under network failure injection;
# any service touching the wire path is in scope.
# Suites below are bash-only today. Entries activate as the Go ports
# land (Bead 2 fixtures unblock new-shape ports for multinode etc.).
#
# e2e-multinode:
# path: spinifex/tests/e2e/multinode
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# e2e-reboot:
# path: spinifex/tests/e2e/reboot
# covers: [spinifex_daemon, viperblockd]
# e2e-tofu:
# path: spinifex/tests/e2e/tofu
# covers: [awsgw, spinifex_daemon, vpcd]
# e2e-bm:
# path: spinifex/tests/e2e/baremetal
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Special-case rules consumed by the selector (Bead 4). Hard-coded in
# the tool, listed here for visibility.
#
# infra_paths_force_all:
# - spinifex/services/nats/**
# - spinifex/tests/e2e/harness/**
# - spinifex/tests/e2e/lib/**
# - spinifex/.github/workflows/e2e-go.yml
# - spinifex/.github/scripts/**
# - spinifex/Makefile
# - mulga/scripts/tofu-cluster/**
#
# submodule_force_all:
# - mulga/predastore (submodule pointer)
# - mulga/viperblock (submodule pointer)
#
# many_services_force_all_threshold: 3 # ≥ N services touched → run all
@@ -0,0 +1,47 @@
// Command manifest-check validates spinifex/docs/service-interfaces.yaml.
//
// Usage: manifest-check [-manifest path] [-repo-root path]
//
// Exits non-zero with one error per line on validation failure.
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
manifestcheck "github.com/mulgadc/spinifex/tests/e2e/manifest-check"
)
func main() {
var manifestPath, repoRoot string
flag.StringVar(&manifestPath, "manifest", "docs/service-interfaces.yaml", "path to the manifest YAML")
flag.StringVar(&repoRoot, "repo-root", ".", "spinifex repo root for resolving service/suite paths")
flag.Parse()
root, err := filepath.Abs(repoRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "resolve repo-root: %v\n", err)
os.Exit(2)
}
if !filepath.IsAbs(manifestPath) {
manifestPath = filepath.Join(root, manifestPath)
}
m, err := manifestcheck.Load(manifestPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
errs := manifestcheck.Validate(m, root)
if len(errs) == 0 {
fmt.Printf("manifest-check: %s OK (%d services, %d suites)\n", manifestPath, len(m.Services), len(m.Suites))
return
}
for _, e := range errs {
fmt.Fprintln(os.Stderr, e)
}
os.Exit(1)
}
+142
View File
@@ -0,0 +1,142 @@
// Package manifestcheck loads and validates spinifex/docs/service-interfaces.yaml.
//
// The validator is documentation-only at this stage of Bead 1: it confirms
// the manifest parses, that every declared service/suite path exists on
// disk, and that cross-references (suites.covers → services, fixtures →
// services) resolve. It does not yet enforce that subjects in the manifest
// match subjects in source — that's Bead 5 (e2e-manifest-drift-lint).
package manifestcheck
import (
"bytes"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"gopkg.in/yaml.v3"
)
type Manifest struct {
Version int `yaml:"version"`
Services map[string]Service `yaml:"services"`
Fixtures map[string]Fixture `yaml:"fixtures"`
Suites map[string]Suite `yaml:"suites"`
}
type Service struct {
Path string `yaml:"path"`
AdditionalPaths []string `yaml:"additional_paths,omitempty"`
Subscribes []string `yaml:"subscribes"`
Publishes []string `yaml:"publishes"`
DependsOn []string `yaml:"depends_on"`
CoversNoSuite bool `yaml:"covers_no_suite,omitempty"`
}
type Fixture struct {
Services []string `yaml:"services"`
}
type Suite struct {
Path string `yaml:"path"`
Covers []string `yaml:"covers"`
}
func Load(path string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var m Manifest
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
if err := dec.Decode(&m); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &m, nil
}
// Validate checks structural invariants of the manifest. repoRoot is the
// directory paths are resolved against (typically the spinifex repo root).
// Returns one error per violation; the caller decides how to report them.
func Validate(m *Manifest, repoRoot string) []error {
var errs []error
if m.Version != 1 {
errs = append(errs, fmt.Errorf("version: want 1, got %d", m.Version))
}
if len(m.Services) == 0 {
errs = append(errs, fmt.Errorf("services: must declare at least one service"))
}
subjectRE := regexp.MustCompile(`^[a-z][a-z0-9]*(\.[a-zA-Z][a-zA-Z0-9_-]*|\.\*)+$`)
for name, svc := range m.Services {
if svc.Path == "" {
errs = append(errs, fmt.Errorf("services.%s: path is required", name))
} else if !dirExists(filepath.Join(repoRoot, svc.Path)) {
errs = append(errs, fmt.Errorf("services.%s: path %q does not exist", name, svc.Path))
}
for _, p := range svc.AdditionalPaths {
if !dirExists(filepath.Join(repoRoot, p)) {
errs = append(errs, fmt.Errorf("services.%s: additional_paths %q does not exist", name, p))
}
}
errs = append(errs, validateSubjects(name+".subscribes", svc.Subscribes, subjectRE)...)
errs = append(errs, validateSubjects(name+".publishes", svc.Publishes, subjectRE)...)
errs = append(errs, validateSubjects(name+".depends_on", svc.DependsOn, subjectRE)...)
}
for fxName, fx := range m.Fixtures {
for _, svcName := range fx.Services {
if _, ok := m.Services[svcName]; !ok {
errs = append(errs, fmt.Errorf("fixtures.%s: references unknown service %q", fxName, svcName))
}
}
}
for suiteName, suite := range m.Suites {
if suite.Path == "" {
errs = append(errs, fmt.Errorf("suites.%s: path is required", suiteName))
} else if !dirExists(filepath.Join(repoRoot, suite.Path)) {
errs = append(errs, fmt.Errorf("suites.%s: path %q does not exist", suiteName, suite.Path))
}
if len(suite.Covers) == 0 {
errs = append(errs, fmt.Errorf("suites.%s: covers must list at least one service", suiteName))
}
for _, svcName := range suite.Covers {
svc, ok := m.Services[svcName]
if !ok {
errs = append(errs, fmt.Errorf("suites.%s.covers: references unknown service %q", suiteName, svcName))
continue
}
if svc.CoversNoSuite {
errs = append(errs, fmt.Errorf("suites.%s.covers: service %q is marked covers_no_suite", suiteName, svcName))
}
}
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
return errs
}
func validateSubjects(ctx string, subjects []string, re *regexp.Regexp) []error {
var errs []error
seen := map[string]struct{}{}
for _, s := range subjects {
if !re.MatchString(s) {
errs = append(errs, fmt.Errorf("%s: %q is not a valid NATS subject", ctx, s))
}
if _, dup := seen[s]; dup {
errs = append(errs, fmt.Errorf("%s: duplicate subject %q", ctx, s))
}
seen[s] = struct{}{}
}
return errs
}
func dirExists(p string) bool {
info, err := os.Stat(p)
return err == nil && info.IsDir()
}
+191
View File
@@ -0,0 +1,191 @@
package manifestcheck
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// repoRoot resolves the spinifex repo root from the test file's location.
// tests/e2e/manifest-check/ → spinifex repo root is three dirs up.
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
return filepath.Join(filepath.Dir(file), "..", "..", "..")
}
func TestLoadAndValidate_RealManifest(t *testing.T) {
root := repoRoot(t)
path := filepath.Join(root, "docs", "service-interfaces.yaml")
m, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if errs := Validate(m, root); len(errs) > 0 {
for _, e := range errs {
t.Errorf("validate: %v", e)
}
}
}
func writeTmp(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "manifest.yaml")
if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return p
}
func TestValidate_VersionMismatch(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 2,
Services: map[string]Service{
"awsgw": {Path: "spinifex/services/awsgw", Subscribes: []string{}, Publishes: []string{}, DependsOn: []string{}},
},
}
errs := Validate(m, root)
if !containsErr(errs, "version: want 1") {
t.Errorf("expected version error, got %v", errs)
}
}
func TestValidate_MissingServicePath(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 1,
Services: map[string]Service{
"phantom": {Path: "spinifex/services/does-not-exist"},
},
}
errs := Validate(m, root)
if !containsErr(errs, "phantom: path") {
t.Errorf("expected missing-path error, got %v", errs)
}
}
func TestValidate_BadSubject(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 1,
Services: map[string]Service{
"awsgw": {
Path: "spinifex/services/awsgw",
Subscribes: []string{"NoDots", "ec2..bad", "ec2.Good"},
},
},
}
errs := Validate(m, root)
bad := 0
for _, e := range errs {
if strings.Contains(e.Error(), "is not a valid NATS subject") {
bad++
}
}
if bad < 2 {
t.Errorf("expected ≥2 subject errors, got %d (%v)", bad, errs)
}
}
func TestValidate_DuplicateSubject(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 1,
Services: map[string]Service{
"awsgw": {
Path: "spinifex/services/awsgw",
Publishes: []string{"ec2.RunInstances", "ec2.RunInstances"},
},
},
}
errs := Validate(m, root)
if !containsErr(errs, "duplicate subject") {
t.Errorf("expected duplicate-subject error, got %v", errs)
}
}
func TestValidate_SuiteUnknownService(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 1,
Services: map[string]Service{
"awsgw": {Path: "spinifex/services/awsgw"},
},
Suites: map[string]Suite{
"e2e-cert": {
Path: "spinifex/tests/e2e/cert",
Covers: []string{"awsgw", "ghost_service"},
},
},
}
errs := Validate(m, root)
if !containsErr(errs, `unknown service "ghost_service"`) {
t.Errorf("expected unknown-service error, got %v", errs)
}
}
func TestValidate_CoversNoSuiteConflict(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 1,
Services: map[string]Service{
"spinifexui": {Path: "spinifex/services/spinifexui", CoversNoSuite: true},
},
Suites: map[string]Suite{
"e2e-cert": {
Path: "spinifex/tests/e2e/cert",
Covers: []string{"spinifexui"},
},
},
}
errs := Validate(m, root)
if !containsErr(errs, "covers_no_suite") {
t.Errorf("expected covers_no_suite conflict, got %v", errs)
}
}
func TestValidate_FixtureUnknownService(t *testing.T) {
root := repoRoot(t)
m := &Manifest{
Version: 1,
Services: map[string]Service{
"awsgw": {Path: "spinifex/services/awsgw"},
},
Fixtures: map[string]Fixture{
"EnsureAMI": {Services: []string{"awsgw", "ghost"}},
},
}
errs := Validate(m, root)
if !containsErr(errs, `unknown service "ghost"`) {
t.Errorf("expected unknown-service in fixture, got %v", errs)
}
}
func TestLoad_UnknownField(t *testing.T) {
bad := `
version: 1
services:
awsgw:
path: spinifex/services/awsgw
nonsense_field: true
`
if _, err := Load(writeTmp(t, bad)); err == nil {
t.Fatal("expected unknown-field error, got nil")
}
}
func containsErr(errs []error, sub string) bool {
for _, e := range errs {
if strings.Contains(e.Error(), sub) {
return true
}
}
return false
}