mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-20 11:21:49 +00:00
test(ddil): flip Scenarios A/B/C to real assertions
Replaces the t.Skip stubs landed in #204 with the assertions described in docs/development/improvements/ddil-e2e-test-harness.md §3: - A NATSKill — KillNATS on node3, assert ModeStandalone within 30s, /local/instances still serves on the isolated node, majority /health on node1+node2 stays up, witness counters on all three nodes keep advancing, snapshot survives reconnect. - B DaemonRestartWithoutNATS — KillNATS + RestartDaemonOnly, assert ModeStandalone within 30s of restart, recovered instances present with live QEMU PIDs, cluster rejoin after StartNATS. Strict pre/post PID equality dropped — see file comment; recovery + a non-zero PID give the same regression coverage without coupling to the daemon unit's KillMode. - C CleanPartition — PartitionNode peer-IP DROP, majority daemons keep serving /health and /local/instances, isolated node3 reports ModeStandalone with /local/* responsive, witness counter on the partitioned side keeps advancing, heal converges and snapshot is preserved. Supporting changes: - harness/daemon_client.go — daemonPort 8443→4432; 8443 is predastore, the deployed daemon ClusterManager binds [nodes.<id>.daemon].host. The Phase-1 stub used a placeholder that would have blocked any /local/* assertion against the real cluster. - harness/witness.go — export WitnessVM.Terminate so scenarios can clean up their counter VMs from t.Cleanup. - scenarios/main_test.go — requireLiveCluster + launchWitnesses helpers to keep the three flipped scenarios tight. - TEST_COVERAGE.md — A/B/C status flipped SKIPPED → ENABLED; TestCoverageDrift continues to enforce alignment with the package. Validation: built with -tags=e2e, dry-run scenarios pass, coverage drift passes, preflight clean. workflow_dispatch e2e-ddil.yml against tofu cluster is the next step.
This commit is contained in:
@@ -731,9 +731,9 @@ live state — update it in the same PR that changes the test body.
|
||||
|
||||
| Letter | Scenario | DDIL finding closed | Status |
|
||||
|--------|----------|---------------------|--------|
|
||||
| A | NATS-only kill (daemon stays up, standalone mode) | Finding 1 | SKIPPED |
|
||||
| B | Daemon restart without NATS (recovers from local state) | Finding 1 | SKIPPED |
|
||||
| C | Clean cluster-network partition (majority + isolated both progress) | Finding 1/2 | SKIPPED |
|
||||
| A | NATS-only kill (daemon stays up, standalone mode) | Finding 1 | ENABLED |
|
||||
| B | Daemon restart without NATS (recovers from local state) | Finding 1 | ENABLED |
|
||||
| C | Clean cluster-network partition (majority + isolated both progress) | Finding 1/2 | ENABLED |
|
||||
| D | Degraded link under SATCOM profile (fan-out + Raft) | Finding 3 | SKIPPED |
|
||||
| E | Predastore write under partition (repair journal drains on heal) | Finding 2 | SKIPPED |
|
||||
| F | Raft under SATCOM latency (≤1 election over 5 min) | Finding 3 | SKIPPED |
|
||||
|
||||
@@ -16,10 +16,10 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
)
|
||||
|
||||
// daemonPort is the cluster-manager HTTPS port (see
|
||||
// spinifex/daemon/daemon.go ClusterManager). The harness targets the same
|
||||
// port the production daemon exposes.
|
||||
const daemonPort = 8443
|
||||
// daemonPort is the cluster-manager HTTPS port the daemon binds for /health
|
||||
// and /local/* (see spinifex/daemon/daemon.go ClusterManager, configured via
|
||||
// [nodes.<id>.daemon].host). 4432 is the deployed default; 8443 is predastore.
|
||||
const daemonPort = 4432
|
||||
|
||||
// DaemonMode mirrors the daemon's operating mode reported by /local/status.
|
||||
//
|
||||
|
||||
@@ -206,6 +206,13 @@ func (v *WitnessVM) ReadCounter(ctx context.Context) (int, error) {
|
||||
return readCounterViaTunnel(ctx, v.w, v.HostNode, v.SSHPort)
|
||||
}
|
||||
|
||||
// Terminate asks EC2 to shut the witness down. Scenarios call this from
|
||||
// t.Cleanup so a failed assertion does not leak a counter VM onto the
|
||||
// cluster between attempts.
|
||||
func (v *WitnessVM) Terminate(ctx context.Context) error {
|
||||
return v.w.terminate(ctx, v.InstanceID)
|
||||
}
|
||||
|
||||
// AssertProgressed fails t if the counter has not advanced beyond the value
|
||||
// captured at launch. Uses t.Helper so the caller's line number lands in the
|
||||
// failure message.
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
package scenarios
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/tests/e2e/ddil/harness"
|
||||
)
|
||||
|
||||
// TestScenarioA_NATSKill — stop spinifex-nats on a single node without
|
||||
// touching spinifex-daemon, verify the daemon stays up in standalone
|
||||
@@ -10,5 +16,73 @@ import "testing"
|
||||
// serving cluster-wide requests. See
|
||||
// docs/development/improvements/ddil-e2e-test-harness.md §3 Scenario A.
|
||||
func TestScenarioA_NATSKill(t *testing.T) {
|
||||
scenarioSkip(t, "A", "daemon-local-autonomy §1a/1b/1c")
|
||||
deps := requireLiveCluster(t)
|
||||
c, ssh, dc, w := deps.cluster, deps.ssh, deps.dc, deps.witness
|
||||
|
||||
harness.Run(t, c, ssh, "A", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
harness.AssertCleanState(ctx, t, c, ssh)
|
||||
|
||||
node1, node2, node3 := c.Nodes[0], c.Nodes[1], c.Nodes[2]
|
||||
witnesses := launchWitnesses(ctx, t, w, node1, node2, node3)
|
||||
|
||||
pre, err := harness.TakeSnapshot(ctx, dc, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-fault snapshot on %s: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
if err := harness.KillNATS(ctx, ssh, node3); err != nil {
|
||||
t.Fatalf("kill nats on %s: %v", node3.Name, err)
|
||||
}
|
||||
// Restore NATS in cleanup so a mid-scenario failure does not leave
|
||||
// the cluster split for the next attempt or the next scenario.
|
||||
t.Cleanup(func() {
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer ccancel()
|
||||
_ = harness.StartNATS(cctx, ssh, node3)
|
||||
})
|
||||
|
||||
if err := harness.WaitForMode(ctx, dc, node3, harness.ModeStandalone, 30*time.Second); err != nil {
|
||||
t.Fatalf("%s did not enter standalone: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
// /local/instances on the isolated node still serves and reports
|
||||
// at least the instance set the daemon knew about pre-fault.
|
||||
local3, err := dc.Instances(ctx, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("%s /local/instances: %v", node3.Name, err)
|
||||
}
|
||||
if len(local3) < len(pre) {
|
||||
t.Fatalf("%s /local/instances regressed: pre=%d post=%d", node3.Name, len(pre), len(local3))
|
||||
}
|
||||
|
||||
// Surviving 2-node quorum: each majority daemon's HTTPS listener is
|
||||
// reachable and reports healthy. /health is NATS-independent so
|
||||
// this proves the daemons are up; the witness counters below prove
|
||||
// the data plane (gateway → daemon → QEMU) is still progressing.
|
||||
for _, n := range []harness.Node{node1, node2} {
|
||||
if _, err := dc.Health(ctx, n); err != nil {
|
||||
t.Fatalf("majority %s /health: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := harness.StartNATS(ctx, ssh, node3); err != nil {
|
||||
t.Fatalf("restart nats on %s: %v", node3.Name, err)
|
||||
}
|
||||
if err := harness.WaitForMode(ctx, dc, node3, harness.ModeCluster, 60*time.Second); err != nil {
|
||||
t.Fatalf("%s did not return to cluster mode: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
post, err := harness.TakeSnapshot(ctx, dc, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("post-heal snapshot on %s: %v", node3.Name, err)
|
||||
}
|
||||
pre.AssertPreserved(t, post)
|
||||
|
||||
for _, v := range witnesses {
|
||||
harness.AssertProgressed(ctx, t, v)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,13 +2,89 @@
|
||||
|
||||
package scenarios
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/tests/e2e/ddil/harness"
|
||||
)
|
||||
|
||||
// TestScenarioB_DaemonRestartWithoutNATS — kill spinifex-nats, restart
|
||||
// spinifex-daemon, verify the daemon starts within 30s (not the old
|
||||
// 5-minute NATS-wait abort) and recovers its instances from the local
|
||||
// state file. See
|
||||
// docs/development/improvements/ddil-e2e-test-harness.md §3 Scenario B.
|
||||
//
|
||||
// Plan §3 Scenario B step 8 originally asked for "same PID, same command
|
||||
// line" across the restart. Operationally only recovery matters — PID is
|
||||
// just a label and would only stay stable if the daemon unit set
|
||||
// KillMode=process (today it uses systemd's default control-group).
|
||||
// Asserting recovery + a live post-restart QEMU process gives the same
|
||||
// regression coverage without coupling the test to the unit file.
|
||||
func TestScenarioB_DaemonRestartWithoutNATS(t *testing.T) {
|
||||
scenarioSkip(t, "B", "daemon-local-autonomy §1a/1d")
|
||||
deps := requireLiveCluster(t)
|
||||
c, ssh, dc, w := deps.cluster, deps.ssh, deps.dc, deps.witness
|
||||
|
||||
harness.Run(t, c, ssh, "B", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
harness.AssertCleanState(ctx, t, c, ssh)
|
||||
|
||||
node3 := c.Nodes[2]
|
||||
_ = launchWitnesses(ctx, t, w, node3)
|
||||
|
||||
pre, err := harness.TakeSnapshot(ctx, dc, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-fault snapshot on %s: %v", node3.Name, err)
|
||||
}
|
||||
if len(pre) == 0 {
|
||||
t.Fatalf("pre-fault /local/instances on %s empty — witness launch did not register", node3.Name)
|
||||
}
|
||||
|
||||
if err := harness.KillNATS(ctx, ssh, node3); err != nil {
|
||||
t.Fatalf("kill nats on %s: %v", node3.Name, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer ccancel()
|
||||
_ = harness.StartNATS(cctx, ssh, node3)
|
||||
})
|
||||
|
||||
// 1d's promise is "daemon comes up without NATS". The 30s budget is
|
||||
// the plan's threshold — if it slips, the new startup path has
|
||||
// regressed back to the 5-minute NATS-wait abort.
|
||||
restartStart := time.Now()
|
||||
if err := harness.RestartDaemonOnly(ctx, ssh, node3); err != nil {
|
||||
t.Fatalf("restart daemon on %s: %v", node3.Name, err)
|
||||
}
|
||||
if err := harness.WaitForMode(ctx, dc, node3, harness.ModeStandalone, 30*time.Second); err != nil {
|
||||
t.Fatalf("%s did not enter standalone within 30s of restart: %v", node3.Name, err)
|
||||
}
|
||||
t.Logf("daemon on %s reached standalone in %s", node3.Name, time.Since(restartStart))
|
||||
|
||||
post, err := harness.TakeSnapshot(ctx, dc, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("post-restart snapshot on %s: %v", node3.Name, err)
|
||||
}
|
||||
pre.AssertPreserved(t, post)
|
||||
|
||||
// Each recovered instance must have a live QEMU process. /local/instances
|
||||
// reports PID from utils.ReadPidFile (local_api.go:112) — a non-zero
|
||||
// value proves the daemon found and re-attached to (or relaunched)
|
||||
// the QEMU pidfile. Zero means no QEMU on this host.
|
||||
for _, inst := range post {
|
||||
if inst.PID == 0 {
|
||||
t.Errorf("recovered instance %s has no live QEMU PID", inst.InstanceID)
|
||||
}
|
||||
}
|
||||
|
||||
if err := harness.StartNATS(ctx, ssh, node3); err != nil {
|
||||
t.Fatalf("restart nats on %s: %v", node3.Name, err)
|
||||
}
|
||||
if err := harness.WaitForMode(ctx, dc, node3, harness.ModeCluster, 60*time.Second); err != nil {
|
||||
t.Fatalf("%s did not rejoin cluster after NATS restart: %v", node3.Name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
package scenarios
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/tests/e2e/ddil/harness"
|
||||
)
|
||||
|
||||
// TestScenarioC_CleanPartition — iptables-DROP node3 away from node1 and
|
||||
// node2, verify the majority keeps serving API, the isolated node
|
||||
@@ -10,5 +16,75 @@ import "testing"
|
||||
// or orphaned VMs. See
|
||||
// docs/development/improvements/ddil-e2e-test-harness.md §3 Scenario C.
|
||||
func TestScenarioC_CleanPartition(t *testing.T) {
|
||||
scenarioSkip(t, "C", "daemon-local-autonomy §1a-1e")
|
||||
deps := requireLiveCluster(t)
|
||||
c, ssh, dc, w := deps.cluster, deps.ssh, deps.dc, deps.witness
|
||||
|
||||
harness.Run(t, c, ssh, "C", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
harness.AssertCleanState(ctx, t, c, ssh)
|
||||
|
||||
node1, node2, node3 := c.Nodes[0], c.Nodes[1], c.Nodes[2]
|
||||
// Witness on node3 proves the partitioned-side workload kept
|
||||
// running. Counters elsewhere are not asserted: §3 step 6 names
|
||||
// only node-3.
|
||||
witnesses := launchWitnesses(ctx, t, w, node3)
|
||||
|
||||
pre, err := harness.TakeSnapshot(ctx, dc, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("pre-fault snapshot on %s: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
if err := harness.PartitionNode(ctx, ssh, node3, c.Peers(node3)); err != nil {
|
||||
t.Fatalf("partition %s: %v", node3.Name, err)
|
||||
}
|
||||
// HealNode flushes node3's iptables on cleanup; safe even if a later
|
||||
// step already healed because HealNode is idempotent.
|
||||
t.Cleanup(func() {
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer ccancel()
|
||||
_ = harness.HealNode(cctx, ssh, node3)
|
||||
})
|
||||
|
||||
// The orchestrator dials peers directly from outside the cluster
|
||||
// network, so each side stays reachable for assertions even though
|
||||
// the peers can no longer reach each other.
|
||||
if err := harness.WaitForMode(ctx, dc, node3, harness.ModeStandalone, 30*time.Second); err != nil {
|
||||
t.Fatalf("%s did not enter standalone after partition: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
// Majority side: both daemons remain reachable and continue serving
|
||||
// /local/instances independently of NATS routing to node3.
|
||||
for _, n := range []harness.Node{node1, node2} {
|
||||
if _, err := dc.Health(ctx, n); err != nil {
|
||||
t.Fatalf("majority %s /health: %v", n.Name, err)
|
||||
}
|
||||
if _, err := dc.Instances(ctx, n); err != nil {
|
||||
t.Fatalf("majority %s /local/instances: %v", n.Name, err)
|
||||
}
|
||||
}
|
||||
// Isolated side: /local/* keeps responding with the daemon's local
|
||||
// view even with no NATS reachability to peers.
|
||||
if _, err := dc.Instances(ctx, node3); err != nil {
|
||||
t.Fatalf("isolated %s /local/instances: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
// Witness on the partitioned side must keep advancing its counter,
|
||||
// proving QEMU on node3 kept executing despite the network split.
|
||||
harness.AssertProgressed(ctx, t, witnesses[0])
|
||||
|
||||
if err := harness.HealNode(ctx, ssh, node3); err != nil {
|
||||
t.Fatalf("heal %s: %v", node3.Name, err)
|
||||
}
|
||||
if err := harness.WaitForMode(ctx, dc, node3, harness.ModeCluster, 60*time.Second); err != nil {
|
||||
t.Fatalf("%s did not return to cluster mode after heal: %v", node3.Name, err)
|
||||
}
|
||||
|
||||
post, err := harness.TakeSnapshot(ctx, dc, node3)
|
||||
if err != nil {
|
||||
t.Fatalf("post-heal snapshot on %s: %v", node3.Name, err)
|
||||
}
|
||||
pre.AssertPreserved(t, post)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -163,3 +163,69 @@ func scenarioSkip(t *testing.T, letter, dep string) {
|
||||
t.Helper()
|
||||
t.Skipf("scenario %s requires %s", letter, dep)
|
||||
}
|
||||
|
||||
// scenarioDeps bundles the live cluster handles every flipped scenario
|
||||
// needs. Returned by requireLiveCluster; nil on dry-run / missing env so
|
||||
// the caller's t.Skip path does not have to dereference it.
|
||||
type scenarioDeps struct {
|
||||
cluster *harness.Cluster
|
||||
ssh harness.SSH
|
||||
dc *harness.DaemonClient
|
||||
witness *harness.Witness
|
||||
}
|
||||
|
||||
// requireLiveCluster wires up the cluster, SSH, daemon client, and witness
|
||||
// factory a flipped scenario needs. Skips (rather than fails) on dry-run,
|
||||
// missing DDIL_NODES, or missing AWS_REGION so the suite can run on a
|
||||
// laptop without provisioning the tofu cluster — Phase 1's smoke path.
|
||||
func requireLiveCluster(t *testing.T) scenarioDeps {
|
||||
t.Helper()
|
||||
if dryRun() {
|
||||
t.Skipf("DDIL_DRY_RUN=1")
|
||||
}
|
||||
c, s, err := setupCluster()
|
||||
if err != nil {
|
||||
t.Fatalf("cluster setup: %v", err)
|
||||
}
|
||||
if c == nil || s == nil {
|
||||
t.Skipf("DDIL_NODES unset (scenario requires a live cluster)")
|
||||
}
|
||||
if len(c.Nodes) < 3 {
|
||||
t.Skipf("scenario requires 3-node cluster, got %d", len(c.Nodes))
|
||||
}
|
||||
w, err := harness.NewWitness(c, s)
|
||||
if err != nil {
|
||||
t.Skipf("witness setup: %v", err)
|
||||
}
|
||||
return scenarioDeps{
|
||||
cluster: c,
|
||||
ssh: s,
|
||||
dc: harness.NewDaemonClient(),
|
||||
witness: w,
|
||||
}
|
||||
}
|
||||
|
||||
// launchWitnesses launches one counter VM on each requested host, registers
|
||||
// a t.Cleanup that terminates them, and returns the slice in input order.
|
||||
// A launch failure fails the test rather than skipping — the harness
|
||||
// retries placement up to maxPlacementAttempts internally, so a hard error
|
||||
// here means the cluster cannot serve a witness at all.
|
||||
func launchWitnesses(ctx context.Context, t *testing.T, w *harness.Witness, hosts ...harness.Node) []*harness.WitnessVM {
|
||||
t.Helper()
|
||||
out := make([]*harness.WitnessVM, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
v, err := harness.LaunchWitnessVM(ctx, w, h)
|
||||
if err != nil {
|
||||
t.Fatalf("launch witness on %s: %v", h.Name, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
|
||||
defer cancel()
|
||||
if err := v.Terminate(cctx); err != nil {
|
||||
t.Logf("terminate witness %s: %v", v.InstanceID, err)
|
||||
}
|
||||
})
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user