mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
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:
@@ -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)...."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user