From 6d38fcad5c2c8b207737a921eed5d2ebb2c3d22b Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Tue, 19 May 2026 23:26:42 +1000 Subject: [PATCH] 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. --- Makefile | 9 +- docs/DESIGN.md | 7 + docs/service-interfaces.yaml | 274 ++++++++++++++++++ .../manifest-check/cmd/manifest-check/main.go | 47 +++ tests/e2e/manifest-check/manifest.go | 142 +++++++++ tests/e2e/manifest-check/manifest_test.go | 191 ++++++++++++ 6 files changed, 669 insertions(+), 1 deletion(-) create mode 100644 docs/service-interfaces.yaml create mode 100644 tests/e2e/manifest-check/cmd/manifest-check/main.go create mode 100644 tests/e2e/manifest-check/manifest.go create mode 100644 tests/e2e/manifest-check/manifest_test.go diff --git a/Makefile b/Makefile index 7c4269347..b5997aede 100644 --- a/Makefile +++ b/Makefile @@ -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)...." diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 1cace99cd..b39ec3746 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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 diff --git a/docs/service-interfaces.yaml b/docs/service-interfaces.yaml new file mode 100644 index 000000000..6054051dd --- /dev/null +++ b/docs/service-interfaces.yaml @@ -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 ".*" 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 diff --git a/tests/e2e/manifest-check/cmd/manifest-check/main.go b/tests/e2e/manifest-check/cmd/manifest-check/main.go new file mode 100644 index 000000000..29673d92f --- /dev/null +++ b/tests/e2e/manifest-check/cmd/manifest-check/main.go @@ -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) +} diff --git a/tests/e2e/manifest-check/manifest.go b/tests/e2e/manifest-check/manifest.go new file mode 100644 index 000000000..01a8c2260 --- /dev/null +++ b/tests/e2e/manifest-check/manifest.go @@ -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() +} diff --git a/tests/e2e/manifest-check/manifest_test.go b/tests/e2e/manifest-check/manifest_test.go new file mode 100644 index 000000000..17260c639 --- /dev/null +++ b/tests/e2e/manifest-check/manifest_test.go @@ -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 +}