mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-21 19:56:33 +00:00
068aef5589
* 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.
208 lines
7.0 KiB
Go
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
|
|
}
|