Files
spinifex/tests/e2e/ddil/harness/reset.go
T
Josh Siviour 068aef5589 feat: DDIL E2E test harness (mulga-siv-5) (#160)
* feat: scaffold DDIL E2E harness (cluster, SSH, daemon client)

Phase 1 of the DDIL E2E test harness (mulga-siv-5.1). Adds the
tests/e2e/ddil/ package tree with the three harness primitives required
by every sibling scenario/fault bead:

- harness/cluster.go — Cluster, Node, ClusterFromEnv, NodeFromEnv
- harness/ssh.go — typed SSH transport backed by golang.org/x/crypto/ssh,
  interface so dry-run and tests can stub it
- harness/daemon_client.go — thin HTTPS wrapper over /local/status,
  /local/instances (placeholder types until daemon-local-autonomy lands),
  plus /health for scaffolding validation

Also adds doc.go and README.md (run instructions + env-var contract). All
harness files carry //go:build e2e so default builds skip them.

Plan: docs/development/improvements/ddil-e2e-test-harness.md §0, §1.

* feat: add DDIL harness fault injection and link profiles

Phase 2 of the DDIL E2E test harness (mulga-siv-5.2). Adds the fault
injection and netem primitives consumed by Scenarios A–F:

- harness/fault.go — PartitionNode (per-peer iptables DROP with
  orchestrator-SSH sanity check), HealNode, KillNATS, StartNATS,
  RestartDaemonOnly, WaitForMode (polls /local/status for typed
  DaemonMode enum)
- harness/netem.go — ApplyNetem (tc qdisc replace, idempotent),
  ClearNetem (idempotent clear)
- harness/profiles.go — LinkProfile struct plus LAN, WAN, LTEDegraded,
  SATCOM, HFData, Flapping constants (unvalidated approximations per
  plan §2 table; validation tracking lives in TEST_COVERAGE.md)

All files carry //go:build e2e. Flapping profile is defined but
ApplyNetem rejects it — flap orchestration lands with the scenarios
that need it.

Plan: docs/development/improvements/ddil-e2e-test-harness.md §1, §2.

* feat: add DDIL harness witness VM helpers

LaunchWitnessVM launches a counter VM via the cluster's AWS gateway,
locates its QEMU host via ps, and captures a baseline counter reading.
ReadCounter/AssertProgressed tunnel SSH through the host's QEMU
hostfwd port to verify the workload progressed across a fault. README
documents the new DDIL_WITNESS_* and DDIL_GUEST_SSH_* env vars.

* feat: add DDIL harness snapshot and state assertions

TakeSnapshot / AssertPreserved compare /local/instances pre and post
fault, failing if IDs disappear or a running/pending instance regresses
to a terminal state. LocalStateRevision / AssertMonotonic read the
local state file revision from /local/status so scenarios can detect
the daemon rewinding its persisted state.

* feat: add DDIL harness reset, retry, and quarantine

ResetAllNodes brings every node back to a clean baseline (iptables -F,
tc netem clear on any interface carrying one, spinifex-nats and
spinifex-daemon started) then waits for cluster health — daemon /health
200 plus NATS routez peer count matching len(nodes)-1, mirroring the
shell suite's verify_nats_cluster predicate.

AssertCleanState is a pre-scenario baseline check that recovers a dirty
state via ResetAllNodes instead of failing outright, since a dirty
baseline usually means a previous scenario's cleanup was skipped.

Run wraps each scenario with quarantine handling (DDIL_QUARANTINED
skip) and a single reset-and-retry on failure. First-attempt failures
are not hidden; the retry is a diagnostic aid rather than a flake mask.

bd: mulga-siv-5.5

* feat: DDIL scenarios skeleton (6x t.Skip + TestMain + coverage drift)

Phase 1 of mulga-siv-5 (DDIL E2E harness): all six scenarios land as
TestScenario<L>_... stubs that t.Skipf on their hardening-epic dependency.
TestMain installs a SIGINT/SIGTERM trap that ResetAllNodes before
exiting; DDIL_DRY_RUN=1 short-circuits cluster setup and logs the planned
scenarios. TestCoverageDrift AST-parses sibling *_test.go files and
reconciles them against the DDIL table in TEST_COVERAGE.md so Phase 2/3
flips cannot silently diverge doc from code. TestHarnessSmoke covers the
AssertCleanState + ResetAllNodes round trip for the "full run on real
cluster" acceptance bullet.

* feat: add DDIL E2E CI workflow (workflow_dispatch only)

Phase 1 CI wiring for mulga-siv-5. Mirrors e2e_real_multi's
provision/bootstrap/teardown shape against the same ci-multi runner and
tofu-cluster, but runs the Go harness on the runner (outside the
cluster) so its SSH path survives the peer-traffic iptables rules that
Scenario C installs on a cluster node. Triggers are workflow_dispatch
only — nightly cron and on-main-merge come in mulga-siv-5.8 once ≥4
scenarios are asserting. results.json (go test -json) uploads as an
artifact so flake-rate visibility can be built on gh run view +
structured output, per the design doc.
2026-04-23 22:38:29 +10:00

208 lines
7.0 KiB
Go

//go:build e2e
package harness
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"testing"
"time"
)
// routezResponse is the subset of the NATS /routez monitoring payload the
// harness consumes. Only remote_name is required because the harness counts
// unique peer names (not raw route count — NATS opens multiple TCP routes
// per peer and num_routes would overcount). The shell suite's
// verify_nats_cluster helper uses the same predicate.
type routezResponse struct {
Routes []struct {
RemoteName string `json:"remote_name"`
} `json:"routes"`
}
// ResetAllNodes returns the cluster to a known clean state: every node has
// all iptables rules flushed, every tc netem qdisc removed, and both
// spinifex-nats and spinifex-daemon running. After the per-node reset, it
// waits for cluster health (every daemon /health returns 200 and each node's
// NATS monitor reports len(Nodes)-1 unique route peers) before returning.
//
// Idempotent — safe against a clean cluster. Three callers:
// 1. harness.Run, between a failed scenario and its retry.
// 2. AssertCleanState, when a pre-scenario baseline is dirty.
// 3. The suite-level signal handler in TestMain, so SIGINT/SIGTERM do not
// leave the cluster partitioned or netem-shaped.
func ResetAllNodes(ctx context.Context, c *Cluster, ssh SSH) error {
for _, n := range c.Nodes {
if err := resetNode(ctx, ssh, n); err != nil {
return err
}
}
return waitClusterHealthy(ctx, c, ssh, 2*time.Minute)
}
func resetNode(ctx context.Context, ssh SSH, n Node) error {
// Flush every DDIL-owned firewall rule. iptables -F on the default
// (filter) table covers INPUT/OUTPUT DROPs installed by PartitionNode;
// iptables -X removes any transient user chains a scenario happens to
// leave behind. This matches HealNode's teardown.
if _, err := ssh.Run(ctx, n, "sudo iptables -F && sudo iptables -X"); err != nil {
return fmt.Errorf("ddil harness: reset iptables on %s: %w", n.Name, err)
}
// Remove netem qdiscs only on interfaces that actually carry one.
// Scenarios may pick different NICs (eth0, ens18, br-mgmt) and a fixed
// interface list here would silently miss whichever one was used.
ifaces, err := netemIfaces(ctx, ssh, n)
if err != nil {
return err
}
for _, iface := range ifaces {
cmd := fmt.Sprintf("sudo tc qdisc del dev %s root 2>/dev/null || true", shellQuote(iface))
if _, err := ssh.Run(ctx, n, cmd); err != nil {
return fmt.Errorf("ddil harness: clear netem on %s (%s): %w", n.Name, iface, err)
}
}
// Start services — noop if already active (systemd returns 0).
if _, err := ssh.Run(ctx, n, "sudo systemctl start spinifex-nats spinifex-daemon"); err != nil {
return fmt.Errorf("ddil harness: start services on %s: %w", n.Name, err)
}
return nil
}
// netemIfaces lists every interface on node whose root qdisc is a netem
// qdisc. Used by resetNode to decide what to tear down and by
// AssertCleanState to detect a dirty baseline.
func netemIfaces(ctx context.Context, ssh SSH, n Node) ([]string, error) {
// `tc qdisc show` prints one row per qdisc; `qdisc netem 8003: dev eth0
// root ...` → awk $5 yields the interface name.
out, err := ssh.Run(ctx, n, "tc qdisc show | awk '/qdisc netem/ {print $5}' | sort -u")
if err != nil {
return nil, fmt.Errorf("ddil harness: list netem qdiscs on %s: %w", n.Name, err)
}
return strings.Fields(string(out)), nil
}
func waitClusterHealthy(ctx context.Context, c *Cluster, ssh SSH, timeout time.Duration) error {
dc := NewDaemonClient()
expectedPeers := len(c.Nodes) - 1
deadline := time.Now().Add(timeout)
const interval = 2 * time.Second
var lastErr error
for {
if ctx.Err() != nil {
return ctx.Err()
}
if err := checkClusterHealthy(ctx, c, ssh, dc, expectedPeers); err == nil {
return nil
} else {
lastErr = err
}
if time.Now().After(deadline) {
return fmt.Errorf("ddil harness: cluster did not reach healthy within %s: %w", timeout, lastErr)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
}
func checkClusterHealthy(ctx context.Context, c *Cluster, ssh SSH, dc *DaemonClient, expectedPeers int) error {
for _, n := range c.Nodes {
if _, err := dc.Health(ctx, n); err != nil {
return fmt.Errorf("%s daemon health: %w", n.Name, err)
}
peers, err := natsPeerCount(ctx, ssh, n)
if err != nil {
return fmt.Errorf("%s nats peers: %w", n.Name, err)
}
if peers < expectedPeers {
return fmt.Errorf("%s nats peers: have %d, want %d", n.Name, peers, expectedPeers)
}
}
return nil
}
// natsPeerCount SSHes into node, curls the node-local NATS monitor endpoint
// (bound to 127.0.0.1:8222 by the spinifex NATS config), and returns the
// number of unique peer names it reports.
func natsPeerCount(ctx context.Context, ssh SSH, n Node) (int, error) {
out, err := ssh.Run(ctx, n, "curl -fsS http://127.0.0.1:8222/routez")
if err != nil {
return 0, fmt.Errorf("curl routez: %w", err)
}
var rz routezResponse
if err := json.Unmarshal(out, &rz); err != nil {
return 0, fmt.Errorf("parse routez: %w", err)
}
seen := make(map[string]struct{})
for _, r := range rz.Routes {
if r.RemoteName == "" {
continue
}
seen[r.RemoteName] = struct{}{}
}
return len(seen), nil
}
// AssertCleanState verifies no node carries a DDIL-owned firewall rule or
// netem qdisc before a scenario begins. A dirty baseline is not itself a
// test failure — it usually means a previous scenario's t.Cleanup did not
// run (panic, SIGKILL, CI runner killed) — so the helper logs details and
// calls ResetAllNodes to recover. Only a failure of that reset is fatal.
func AssertCleanState(ctx context.Context, t *testing.T, c *Cluster, ssh SSH) {
t.Helper()
var dirty []string
for _, n := range c.Nodes {
drops, err := countDropRules(ctx, ssh, n)
if err != nil {
t.Fatalf("ddil harness: inspect iptables on %s: %v", n.Name, err)
}
ifaces, err := netemIfaces(ctx, ssh, n)
if err != nil {
t.Fatalf("ddil harness: inspect netem on %s: %v", n.Name, err)
}
if drops > 0 {
dirty = append(dirty, fmt.Sprintf("%s: %d iptables DROP rules", n.Name, drops))
}
if len(ifaces) > 0 {
dirty = append(dirty, fmt.Sprintf("%s: netem on %v", n.Name, ifaces))
}
}
if len(dirty) == 0 {
return
}
t.Logf("ddil harness: dirty baseline before scenario — %s; running ResetAllNodes", strings.Join(dirty, "; "))
if err := ResetAllNodes(ctx, c, ssh); err != nil {
t.Fatalf("ddil harness: reset after dirty baseline: %v", err)
}
}
// countDropRules reports how many DROP rules are installed in INPUT/OUTPUT
// on node. `|| true` at the shell level keeps grep's no-match exit 1 from
// being surfaced as an SSH error.
func countDropRules(ctx context.Context, ssh SSH, n Node) (int, error) {
out, err := ssh.Run(ctx, n, "sudo iptables -S INPUT -S OUTPUT | grep -c -- '-j DROP' || true")
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(out))
if s == "" {
return 0, nil
}
count, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse iptables drop count %q: %w", s, err)
}
return count, nil
}