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.
This commit is contained in:
Josh Siviour
2026-04-23 22:38:29 +10:00
committed by GitHub
parent 604693ea0c
commit 068aef5589
25 changed files with 2240 additions and 1 deletions
+140
View File
@@ -0,0 +1,140 @@
name: Spinifex DDIL E2E
# Phase 1 trigger: workflow_dispatch only. Nightly cron + on-main-merge
# land in mulga-siv-5.8 once ≥4 scenarios are asserting. See
# docs/development/improvements/ddil-e2e-test-harness.md §4 / §Dependencies.
on:
workflow_dispatch:
inputs:
quarantined:
description: "Comma-separated scenario letters to quarantine (A..F)"
required: false
default: ""
env:
GIT_BRANCH: ${{ github.ref_name }}
jobs:
ddil:
name: DDIL Real Multi-Node
runs-on: [ self-hosted, ci-multi ]
# Budget: ~45m go test + tofu provision/bootstrap/teardown on ci-multi.
# In Phase 1 the scenarios all t.Skip, so the real-world runtime is
# dominated by provisioning; the 60m ceiling leaves headroom for
# Phase 2/3 when assertions come online.
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- name: Resolve Mulga Branch
id: mulga-branch
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BRANCH: ${{ github.ref_name }}
run: |
if gh api "repos/mulgadc/mulga/branches/${BRANCH}" >/dev/null 2>&1; then
echo "ref=${BRANCH}" >> "$GITHUB_OUTPUT"
echo "mulga: using branch ${BRANCH}"
else
echo "ref=main" >> "$GITHUB_OUTPUT"
echo "mulga: branch ${BRANCH} not found, falling back to main"
fi
# Full spinifex checkout: the Go harness must run on the runner
# (outside the cluster) so its SSH path survives scenario partitions
# that drop peer-to-peer cluster traffic. Running inside the cluster
# would self-sever when Scenario C isolates a node.
- uses: actions/checkout@v6
name: Checkout Spinifex
with:
path: spinifex
- uses: actions/checkout@v6
name: Checkout Mulga (tofu-cluster)
with:
repository: mulgadc/mulga
ref: ${{ steps.mulga-branch.outputs.ref }}
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run DDIL Scenarios
working-directory: mulga/scripts/tofu-cluster
run: |
set -o pipefail
INVENTORY=$(tofu output -json inventory)
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
REGION=$(echo "$INVENTORY" | jq -r '.region')
# DDIL_NODES must be the cluster-traffic IPs: PartitionNode
# installs iptables DROP rules for each peer, so the runner's
# SSH source IP must differ from these. On single-NIC
# tofu-cluster, wan_ip == cluster IP and the runner's outbound
# IP is distinct — see e2e-ddil-test-harness.md §3 Scenario C.
NODE_IPS=$(echo "$INVENTORY" | jq -r '[.nodes[].wan_ip] | join(",")')
echo "DDIL_NODES=${NODE_IPS}"
mkdir -p "${RUNNER_TEMP}/ddil-results"
cd "${GITHUB_WORKSPACE}/spinifex"
DDIL_NODES="${NODE_IPS}" \
DDIL_SSH_USER="${SSH_USER}" \
DDIL_SSH_KEY="${SSH_KEY}" \
DDIL_QUARANTINED="${{ inputs.quarantined }}" \
AWS_REGION="${REGION}" \
go test -tags=e2e -timeout 45m -json \
./tests/e2e/ddil/scenarios/... \
| tee "${RUNNER_TEMP}/ddil-results/results.json"
- name: Upload DDIL Results
if: always()
uses: actions/upload-artifact@v4
with:
name: ddil-results-${{ github.run_id }}
path: ${{ runner.temp }}/ddil-results
retention-days: 30
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+1 -1
View File
@@ -18,6 +18,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0
gopkg.in/ini.v1 v1.67.1
)
@@ -94,7 +95,6 @@ require (
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.52.0 // indirect
+61
View File
@@ -709,3 +709,64 @@ Requires pool mode with external IPAM (NOT dev_networking).
### Phase 4: Cleanup (trap)
- Terminate app instances, wait for terminated
- Delete key pair, subnet, IGW, VPC
## DDIL (`tests/e2e/ddil/`)
Go-based harness and scenarios that exercise the Denied / Disrupted /
Intermittent / Limited failure modes called out in the DDIL vulnerability
assessment. Design and rationale live in
[`docs/development/improvements/ddil-e2e-test-harness.md`](../../../docs/development/improvements/ddil-e2e-test-harness.md);
the Go helper API lives in
[`tests/e2e/ddil/harness/`](./ddil/harness/) with the authoritative link
profile values in [`harness/profiles.go`](./ddil/harness/profiles.go).
Scenarios begin as `t.Skip("requires <dep>")` and are flipped to real
assertions by the hardening epics they cover (daemon-local-autonomy,
predastore-ddil-hardening). The status column below is the authoritative
live state — update it in the same PR that changes the test body.
`TestCoverageDrift` enforces the letter column against the
`TestScenario<L>_...` functions in `ddil/scenarios/`.
### Scenarios
| 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 |
| 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 |
### Link profile validation
Values in `harness/profiles.go` are unvalidated approximations derived
from public specifications — real-hardware measurement is deferred until
SATCOM/HF access is available. Update the last column when a measurement
lands.
| Profile | Specification source | Validation status | Last measured against real hardware |
|---------|----------------------|-------------------|-------------------------------------|
| LAN | baseline | unvalidated | — |
| WAN | public site-to-site norms | unvalidated | — |
| LTEDegraded | public congested-cellular norms | unvalidated | — |
| SATCOM | published GEO SATCOM figures | unvalidated | — |
| HFData | HF modem specifications | unvalidated | — |
| Flapping | synthetic (10s up / 5s down) | unvalidated | — |
### Known gaps
- Chaos framework (randomised fault injection, long-running stress) —
follow-up plan TBD.
- Multi-day partition — requires long-running CI infrastructure;
follow-up plan TBD.
- Real-hardware SATCOM/HF validation — follow-up plan TBD.
- Clock skew injection — no hardening epic claims Finding 7 yet;
revisit when one lands.
- Dual-NIC cluster network — Scenario C uses peer-IP `iptables` rules on
the single-NIC tofu-cluster today; dedicated cluster NIC is deferred
to `docs/development/improvements/e2e-multinode-dual-nic.md` (TBC).
- Dockerised per-node pseudo-multinode isolation — candidate under
[`docs/development/improvements/docker-e2e-local.md`](../../../docs/development/improvements/docker-e2e-local.md).
- Full Go migration of the rest of the E2E suite — TBD follow-up to the
DDIL harness pilot.
+77
View File
@@ -0,0 +1,77 @@
# DDIL E2E Test Harness
Go-based E2E harness that exercises Spinifex and Predastore against DDIL
(Denied, Disrupted, Intermittent, Limited) failure modes — NATS kill,
daemon-without-NATS restart, cluster partition, degraded links, Predastore
writes under partition, Raft under SATCOM latency.
Design: [`docs/development/improvements/ddil-e2e-test-harness.md`](../../../../docs/development/improvements/ddil-e2e-test-harness.md)
(in the mulga monorepo).
## Layout
```
tests/e2e/ddil/
├── doc.go # package doc
├── README.md # this file
├── harness/ # typed primitives (cluster, SSH, daemon client, ...)
└── scenarios/ # TestScenarioA..F, build tag e2e
```
All test and helper files use `//go:build e2e` so `go build ./...` skips them.
Run with `-tags=e2e`.
## Running
Against a pre-provisioned 3-node Proxmox cluster:
```bash
DDIL_NODES=10.0.0.1,10.0.0.2,10.0.0.3 \
DDIL_SSH_USER=ubuntu \
DDIL_SSH_KEY=$HOME/.ssh/tf-user-ap-southeast-2 \
AWS_REGION=ap-southeast-2 \
go test -tags=e2e -timeout 45m ./tests/e2e/ddil/scenarios/...
```
Run a single scenario:
```bash
go test -tags=e2e -run TestScenarioA ./tests/e2e/ddil/scenarios/...
```
Dry-run mode (no cluster ops; verifies SSH reachability, tool availability on
remote nodes, and runs `TestCoverageDrift` only):
```bash
DDIL_DRY_RUN=1 go test -tags=e2e ./tests/e2e/ddil/scenarios/...
```
Quarantine listed scenarios (failures downgrade to `t.Skip` with a tagged
reason; quarantined scenarios must have a linked bead):
```bash
DDIL_QUARANTINED=D,F go test -tags=e2e ./tests/e2e/ddil/scenarios/...
```
## Environment variables
| Var | Required | Purpose |
|-----|----------|---------|
| `DDIL_NODES` | yes (unless dry-run) | Comma-separated cluster node IPs, order matches scenario references (node1, node2, node3). |
| `DDIL_SSH_USER` | yes (unless dry-run) | SSH login user on each node. |
| `DDIL_SSH_KEY` | yes (unless dry-run) | Path to SSH private key. |
| `DDIL_DRY_RUN` | no | `1` skips cluster ops, runs `TestCoverageDrift` only. |
| `DDIL_QUARANTINED` | no | Comma-separated scenario letters (A..F) to quarantine. |
| `AWS_REGION` | yes for witness scenarios | Region used by the AWS SDK when launching witness VMs via the Spinifex AWS gateway. |
| `DDIL_WITNESS_AMI` | no | Specific AMI ID for witness VMs. If unset, the harness picks the first `ami-ubuntu-*` image registered in the cluster. |
| `DDIL_WITNESS_INSTANCE_TYPE` | no (default `t2.micro`) | EC2 instance type for witness VMs. |
| `DDIL_WITNESS_KEY_NAME` | no (default `spinifex-key`) | EC2 key pair name attached to witness VMs; must match an existing registered key. |
| `DDIL_GUEST_SSH_USER` | no (default `ubuntu`) | SSH login user inside the witness guest. |
| `DDIL_GUEST_SSH_KEY` | no (defaults to `DDIL_SSH_KEY`) | Private key for guest SSH; override when the guest image uses a different key than the host. |
## Scenario status
Scenario-level status (SKIPPED / ENABLED / QUARANTINED) plus profile
validation tracking lives in [`tests/e2e/TEST_COVERAGE.md`](../TEST_COVERAGE.md).
Scenarios begin as `t.Skip("requires <dep>")` and are flipped to real
assertions by the hardening epics they exercise.
+15
View File
@@ -0,0 +1,15 @@
// Package ddil is the root of the DDIL (Denied, Disrupted, Intermittent,
// Limited) E2E test harness.
//
// See docs/development/improvements/ddil-e2e-test-harness.md in the mulga
// monorepo for the full design.
//
// Sub-packages:
// - harness: typed primitives (cluster, SSH, fault injection, witness VMs,
// snapshot/state assertions, cleanup/retry, daemon client).
// - scenarios: test files (build tag e2e) that exercise the hardening
// epics via the harness primitives.
//
// All non-scaffolding files in this tree are tagged //go:build e2e so that
// default go build ./... skips them.
package ddil
+112
View File
@@ -0,0 +1,112 @@
//go:build e2e
// Package harness exports the DDIL E2E test primitives: typed cluster/node
// descriptors, an SSH transport, fault-injection helpers, witness-VM helpers,
// snapshot/state assertions, and the daemon HTTP client used by scenarios.
//
// See docs/development/improvements/ddil-e2e-test-harness.md for the design.
package harness
import (
"errors"
"fmt"
"os"
"strings"
)
// Node identifies a single cluster member addressable over SSH and HTTPS.
//
// Index is the 1-based position the node occupies in the DDIL_NODES list;
// scenarios reference nodes by index (node1, node2, node3) so a fixed
// ordering is required.
type Node struct {
Index int // 1-based position in the cluster
Name string // human-readable tag, e.g. "node1"
Addr string // IP or hostname; used for SSH and as peer address for partition rules
}
// Cluster is an ordered set of Nodes plus shared SSH credentials.
//
// Scenarios receive a *Cluster from main_test.go and use its Nodes slice to
// address individual peers. All helpers that mutate node state take a Node
// (not an index) to keep call sites self-describing.
type Cluster struct {
Nodes []Node
SSHUser string
SSHKeyPath string
}
// Peers returns every node in the cluster except target. The result is used
// by PartitionNode to build per-peer iptables DROP rules.
func (c *Cluster) Peers(target Node) []Node {
out := make([]Node, 0, len(c.Nodes)-1)
for _, n := range c.Nodes {
if n.Index == target.Index {
continue
}
out = append(out, n)
}
return out
}
// ClusterFromEnv builds a Cluster from DDIL_NODES / DDIL_SSH_USER /
// DDIL_SSH_KEY. Nodes are named node1..nodeN in list order.
//
// Returns an error if any required variable is missing or if DDIL_NODES
// contains no non-empty entries. main_test.go is expected to call this once
// and pass the result into every scenario.
func ClusterFromEnv() (*Cluster, error) {
nodesRaw := os.Getenv("DDIL_NODES")
user := os.Getenv("DDIL_SSH_USER")
key := os.Getenv("DDIL_SSH_KEY")
var missing []string
if nodesRaw == "" {
missing = append(missing, "DDIL_NODES")
}
if user == "" {
missing = append(missing, "DDIL_SSH_USER")
}
if key == "" {
missing = append(missing, "DDIL_SSH_KEY")
}
if len(missing) > 0 {
return nil, fmt.Errorf("ddil harness: missing required env: %s", strings.Join(missing, ", "))
}
var nodes []Node
for i, addr := range strings.Split(nodesRaw, ",") {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
nodes = append(nodes, Node{
Index: i + 1,
Name: fmt.Sprintf("node%d", i+1),
Addr: addr,
})
}
if len(nodes) == 0 {
return nil, errors.New("ddil harness: DDIL_NODES contained no addresses")
}
return &Cluster{
Nodes: nodes,
SSHUser: user,
SSHKeyPath: key,
}, nil
}
// NodeFromEnv returns the Nth node (1-based) from the cluster defined by
// DDIL_NODES. Convenience wrapper for single-node helpers; most scenarios
// should call ClusterFromEnv once and index c.Nodes directly.
func NodeFromEnv(index int) (Node, error) {
c, err := ClusterFromEnv()
if err != nil {
return Node{}, err
}
if index < 1 || index > len(c.Nodes) {
return Node{}, fmt.Errorf("ddil harness: node index %d out of range (have %d nodes)", index, len(c.Nodes))
}
return c.Nodes[index-1], nil
}
+139
View File
@@ -0,0 +1,139 @@
//go:build e2e
package harness
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"time"
"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
// DaemonMode mirrors the daemon's operating mode reported by /local/status.
//
// The typed enum avoids stringly-typed assertions in scenarios. The concrete
// endpoint and JSON schema land with daemon-local-autonomy §1b; this file
// predates that PR and its types will be swapped for the daemon's own when
// it merges.
type DaemonMode string
const (
ModeUnknown DaemonMode = ""
ModeCluster DaemonMode = "cluster"
ModeStandalone DaemonMode = "standalone"
)
// LocalStatus is the response shape from GET /local/status.
//
// Placeholder fields only — the authoritative struct ships with
// daemon-local-autonomy §1b. Scenarios that depend on these fields remain
// t.Skip until that epic replaces this definition with an import from the
// daemon package.
type LocalStatus struct {
Node string `json:"node"`
Mode DaemonMode `json:"mode"`
Revision uint64 `json:"revision"`
NATS string `json:"nats"` // "connected" | "disconnected"
}
// LocalInstance is one row of GET /local/instances. Placeholder — see
// LocalStatus.
type LocalInstance struct {
InstanceID string `json:"instance_id"`
State string `json:"state"`
PID int `json:"pid,omitempty"`
}
// DaemonClient is a thin HTTPS client targeting a single daemon's local
// endpoints. It uses one *http.Client per node so connection reuse works
// across calls within a scenario.
type DaemonClient struct {
http *http.Client
}
// NewDaemonClient returns a DaemonClient with TLS verification disabled
// (the tofu-cluster uses self-signed per-node certs). Timeouts are set to
// 5s so a wedged daemon fails fast enough for scenario retry logic.
func NewDaemonClient() *DaemonClient {
return &DaemonClient{
http: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // self-signed test certs
},
},
}
}
func (d *DaemonClient) url(node Node, path string) string {
return "https://" + net.JoinHostPort(node.Addr, strconv.Itoa(daemonPort)) + path
}
func (d *DaemonClient) getJSON(ctx context.Context, node Node, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.url(node, path), nil)
if err != nil {
return fmt.Errorf("ddil harness: daemon %s %s: %w", node.Name, path, err)
}
resp, err := d.http.Do(req)
if err != nil {
return fmt.Errorf("ddil harness: daemon %s %s: %w", node.Name, path, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("ddil harness: daemon %s %s: status %d: %s",
node.Name, path, resp.StatusCode, string(body))
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("ddil harness: daemon %s %s decode: %w", node.Name, path, err)
}
return nil
}
// Status returns the daemon's self-reported local status.
//
// Depends on daemon-local-autonomy §1b. Until that lands, the daemon will
// return 404 and callers should t.Skip.
func (d *DaemonClient) Status(ctx context.Context, node Node) (LocalStatus, error) {
var s LocalStatus
if err := d.getJSON(ctx, node, "/local/status", &s); err != nil {
return LocalStatus{}, err
}
return s, nil
}
// Instances returns the daemon's locally-known instance list.
//
// Depends on daemon-local-autonomy §1a. Until that lands, the daemon will
// return 404 and callers should t.Skip.
func (d *DaemonClient) Instances(ctx context.Context, node Node) ([]LocalInstance, error) {
var xs []LocalInstance
if err := d.getJSON(ctx, node, "/local/instances", &xs); err != nil {
return nil, err
}
return xs, nil
}
// Health hits the daemon's existing /health endpoint. Useful during
// scaffolding validation before the /local/* endpoints exist — confirms
// the harness can reach the daemon at all.
func (d *DaemonClient) Health(ctx context.Context, node Node) (types.NodeHealthResponse, error) {
var h types.NodeHealthResponse
if err := d.getJSON(ctx, node, "/health", &h); err != nil {
return types.NodeHealthResponse{}, err
}
return h, nil
}
+124
View File
@@ -0,0 +1,124 @@
//go:build e2e
package harness
import (
"context"
"fmt"
"strings"
"time"
)
// PartitionNode isolates target from peers by installing per-peer iptables
// DROP rules for both directions. The orchestrator's SSH source IP must not
// be one of peers (it normally isn't: the peers are cluster members, and the
// orchestrator is the CI runner). After applying, a sanity-check SSH echo
// confirms the control plane is still reachable; a lost control-plane SSH
// is a loud error rather than a silent hang.
//
// The rule set is additive (iptables -I INPUT/OUTPUT) so PartitionNode
// composes with any prior non-DDIL rules. HealNode's flush is still the
// correct teardown because the harness owns the target's firewall during a
// scenario.
func PartitionNode(ctx context.Context, ssh SSH, target Node, peers []Node) error {
if len(peers) == 0 {
return fmt.Errorf("ddil harness: PartitionNode %s: no peers supplied", target.Name)
}
var lines []string
for _, p := range peers {
lines = append(lines,
fmt.Sprintf("sudo iptables -I INPUT -s %s -j DROP", shellQuote(p.Addr)),
fmt.Sprintf("sudo iptables -I OUTPUT -d %s -j DROP", shellQuote(p.Addr)),
)
}
cmd := strings.Join(lines, " && ")
if _, err := ssh.Run(ctx, target, cmd); err != nil {
return fmt.Errorf("ddil harness: partition %s: %w", target.Name, err)
}
// Sanity-check: if our SSH path ran through a peer IP, the rules we just
// installed would have severed it. Fail loudly so a future infra change
// (e.g. orchestrator moved onto the cluster network) surfaces immediately.
if _, err := ssh.Run(ctx, target, "echo ok"); err != nil {
return fmt.Errorf("ddil harness: partition %s severed orchestrator SSH "+
"(orchestrator may share peer IPs): %w", target.Name, err)
}
return nil
}
// HealNode flushes and deletes all iptables rules on target, reversing a
// prior PartitionNode. Idempotent: safe on a node with no rules installed.
func HealNode(ctx context.Context, ssh SSH, target Node) error {
if _, err := ssh.Run(ctx, target, "sudo iptables -F && sudo iptables -X"); err != nil {
return fmt.Errorf("ddil harness: heal %s: %w", target.Name, err)
}
return nil
}
// KillNATS stops spinifex-nats on node without touching spinifex-daemon.
// This is the scenario shape the current happy-path suite cannot express —
// `systemctl stop spinifex.target` brings both down together.
func KillNATS(ctx context.Context, ssh SSH, node Node) error {
if _, err := ssh.Run(ctx, node, "sudo systemctl stop spinifex-nats"); err != nil {
return fmt.Errorf("ddil harness: kill nats on %s: %w", node.Name, err)
}
return nil
}
// StartNATS starts spinifex-nats on node. Paired with KillNATS.
func StartNATS(ctx context.Context, ssh SSH, node Node) error {
if _, err := ssh.Run(ctx, node, "sudo systemctl start spinifex-nats"); err != nil {
return fmt.Errorf("ddil harness: start nats on %s: %w", node.Name, err)
}
return nil
}
// RestartDaemonOnly restarts spinifex-daemon without touching spinifex-nats.
// Used by Scenario B to exercise the daemon-without-NATS startup path
// (daemon-local-autonomy §1d).
func RestartDaemonOnly(ctx context.Context, ssh SSH, node Node) error {
if _, err := ssh.Run(ctx, node, "sudo systemctl restart spinifex-daemon"); err != nil {
return fmt.Errorf("ddil harness: restart daemon on %s: %w", node.Name, err)
}
return nil
}
// WaitForMode polls the daemon's /local/status until it reports the expected
// mode or timeout expires. Poll interval is 1s — fast enough that a
// 30-second timeout sees ~30 attempts, slow enough not to flood a recovering
// daemon.
//
// Depends on daemon-local-autonomy §1b. Until that endpoint ships, this
// function will time out; callers gate on t.Skip.
func WaitForMode(ctx context.Context, dc *DaemonClient, node Node, want DaemonMode, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
const interval = 1 * time.Second
var lastErr error
for {
if ctx.Err() != nil {
return ctx.Err()
}
status, err := dc.Status(ctx, node)
if err == nil && status.Mode == want {
return nil
}
lastErr = err
if time.Now().After(deadline) {
if lastErr != nil {
return fmt.Errorf("ddil harness: wait for mode %s on %s: timed out after %s: last error: %w",
want, node.Name, timeout, lastErr)
}
return fmt.Errorf("ddil harness: wait for mode %s on %s: timed out after %s (still reporting another mode)",
want, node.Name, timeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build e2e
package harness
import (
"context"
"fmt"
"strings"
)
// ApplyNetem installs a tc netem qdisc on iface of node matching profile.
// Idempotent: any existing root qdisc on the interface is replaced.
//
// Flapping profiles have no static tc expression — they are driven by a
// separate orchestrator (not yet implemented). ApplyNetem rejects them with
// a clear error so callers don't silently apply a no-op.
func ApplyNetem(ctx context.Context, ssh SSH, node Node, iface string, profile LinkProfile) error {
if profile.Flapping != nil {
return fmt.Errorf("ddil harness: profile %q is flap-driven; use a flap orchestrator, not ApplyNetem", profile.Name)
}
var netemArgs []string
if profile.Delay > 0 {
delay := fmt.Sprintf("%dms", profile.Delay.Milliseconds())
if profile.Jitter > 0 {
delay += fmt.Sprintf(" %dms distribution normal", profile.Jitter.Milliseconds())
}
netemArgs = append(netemArgs, "delay", delay)
}
if profile.Loss > 0 {
netemArgs = append(netemArgs, "loss", fmt.Sprintf("%.3f%%", profile.Loss))
}
if profile.Bandwidth != "" {
netemArgs = append(netemArgs, "rate", profile.Bandwidth)
}
if len(netemArgs) == 0 {
return fmt.Errorf("ddil harness: profile %q has no netem parameters", profile.Name)
}
// `tc qdisc replace` is idempotent — it adds if absent, replaces if
// present — which matches ApplyNetem's contract.
cmd := fmt.Sprintf("sudo tc qdisc replace dev %s root netem %s",
shellQuote(iface), strings.Join(netemArgs, " "))
if _, err := ssh.Run(ctx, node, cmd); err != nil {
return fmt.Errorf("ddil harness: apply netem %s on %s (%s): %w",
profile.Name, node.Name, iface, err)
}
return nil
}
// ClearNetem removes any root qdisc on iface. Safe to call when no qdisc is
// present — tc's non-zero exit is swallowed on the remote side.
func ClearNetem(ctx context.Context, ssh SSH, node Node, iface string) error {
cmd := fmt.Sprintf("sudo tc qdisc del dev %s root 2>/dev/null || true", shellQuote(iface))
if _, err := ssh.Run(ctx, node, cmd); err != nil {
return fmt.Errorf("ddil harness: clear netem on %s (%s): %w", node.Name, iface, err)
}
return nil
}
// shellQuote wraps s in single quotes and escapes embedded single quotes, so
// that shell metacharacters in interface names or peer addresses are treated
// as literals on the remote shell.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+79
View File
@@ -0,0 +1,79 @@
//go:build e2e
package harness
import "time"
// LinkProfile describes a tc netem configuration approximating a real-world
// tactical link. Values are unvalidated approximations derived from public
// specifications; see docs/development/improvements/ddil-e2e-test-harness.md
// §2 and spinifex/tests/e2e/TEST_COVERAGE.md for validation tracking.
type LinkProfile struct {
Name string
Delay time.Duration
Jitter time.Duration
Loss float64 // percent, 0..100
Bandwidth string // tc-compatible rate, e.g. "512kbit"; empty means unshaped
Flapping *FlapSpec
}
// FlapSpec toggles link up/down for profiles that simulate jammed or
// intermittent RF. The flap loop itself is not driven by ApplyNetem — it is
// orchestrated by a higher-level helper (added with the scenarios that use
// it) so scenarios keep full control over when the flap stops.
type FlapSpec struct {
Up time.Duration
Down time.Duration
}
// Authoritative DDIL link profile values. See the §2 table in the design doc
// for the rationale behind each row. Replace with measured values when
// real-hardware access is available.
var (
LAN = LinkProfile{
Name: "LAN",
Delay: 1 * time.Millisecond,
Jitter: 0,
Loss: 0,
Bandwidth: "1gbit",
}
WAN = LinkProfile{
Name: "WAN",
Delay: 50 * time.Millisecond,
Jitter: 10 * time.Millisecond,
Loss: 0.1,
Bandwidth: "100mbit",
}
LTEDegraded = LinkProfile{
Name: "LTEDegraded",
Delay: 100 * time.Millisecond,
Jitter: 30 * time.Millisecond,
Loss: 5,
Bandwidth: "1mbit",
}
SATCOM = LinkProfile{
Name: "SATCOM",
Delay: 600 * time.Millisecond,
Jitter: 50 * time.Millisecond,
Loss: 2,
Bandwidth: "512kbit",
}
HFData = LinkProfile{
Name: "HFData",
Delay: 2000 * time.Millisecond,
Jitter: 200 * time.Millisecond,
Loss: 10,
Bandwidth: "9600bit",
}
Flapping = LinkProfile{
Name: "Flapping",
Flapping: &FlapSpec{
Up: 10 * time.Second,
Down: 5 * time.Second,
},
}
)
// AllProfiles lists every predefined profile. Useful for doc-drift checks and
// for the TEST_COVERAGE.md profile-validation table.
var AllProfiles = []LinkProfile{LAN, WAN, LTEDegraded, SATCOM, HFData, Flapping}
+207
View File
@@ -0,0 +1,207 @@
//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
}
+67
View File
@@ -0,0 +1,67 @@
//go:build e2e
package harness
import (
"context"
"os"
"strings"
"testing"
"time"
)
// Run wraps a scenario with reset-and-retry plus quarantine handling. It is
// the primary entry point used by the files in scenarios/:
//
// harness.Run(t, cluster, ssh, "A", func(t *testing.T) { ... })
//
// Quarantine: scenarios whose letter appears in DDIL_QUARANTINED are skipped
// instead of executed. Per the design doc, a quarantined scenario must have
// a linked bead tracking its fix; the live status is tracked in
// tests/e2e/TEST_COVERAGE.md.
//
// Retry: a first-attempt failure runs ResetAllNodes and invokes fn once more
// as a fresh subtest. Both attempts appear in the test output so CI logs
// preserve the transient-vs-deterministic signal. The first-attempt failure
// is not hidden — Go's testing package propagates subtest failures to the
// parent — which is intentional: the retry is a diagnostic aid, not a way
// to mask flakes. Scenarios crossing the 5% nightly flake threshold are
// moved to DDIL_QUARANTINED instead.
func Run(t *testing.T, c *Cluster, ssh SSH, letter string, fn func(*testing.T)) {
t.Helper()
if IsQuarantined(letter) {
t.Skipf("QUARANTINED: scenario %s (DDIL_QUARANTINED=%s)", letter, os.Getenv("DDIL_QUARANTINED"))
return
}
if t.Run("attempt-1", fn) {
return
}
t.Logf("ddil harness: scenario %s failed first attempt, resetting cluster and retrying", letter)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := ResetAllNodes(ctx, c, ssh); err != nil {
t.Errorf("ddil harness: reset before retry of %s: %v", letter, err)
return
}
t.Run("attempt-2", fn)
}
// IsQuarantined reports whether letter appears in DDIL_QUARANTINED.
// Comparison is case-insensitive; whitespace and empty entries are tolerated
// so the env var can be edited by humans without worrying about formatting.
func IsQuarantined(letter string) bool {
raw := os.Getenv("DDIL_QUARANTINED")
if raw == "" {
return false
}
want := strings.ToUpper(strings.TrimSpace(letter))
for _, part := range strings.Split(raw, ",") {
if strings.ToUpper(strings.TrimSpace(part)) == want {
return true
}
}
return false
}
+113
View File
@@ -0,0 +1,113 @@
//go:build e2e
package harness
import (
"context"
"fmt"
"testing"
)
// InstanceSnapshot captures a point-in-time view of a single daemon's locally
// known instances. Scenarios take a snapshot before injecting a fault and a
// second snapshot after the cluster heals, then call AssertPreserved to prove
// the daemon did not lose or regress any instance it had been tracking.
//
// Reuses the LocalInstance type from daemon_client.go. Once daemon-local-
// autonomy §1a lands, that placeholder will be replaced by the daemon's
// authoritative struct and this alias will pick up the real schema without
// scenario changes.
type InstanceSnapshot []LocalInstance
// TakeSnapshot reads /local/instances on the given node and returns the
// response as an InstanceSnapshot.
//
// The DaemonClient is passed explicitly (rather than through a package-level
// singleton) so each scenario owns its connection reuse and so dry-run
// scenarios can stub with a fake client once the endpoint exists.
func TakeSnapshot(ctx context.Context, d *DaemonClient, node Node) (InstanceSnapshot, error) {
xs, err := d.Instances(ctx, node)
if err != nil {
return nil, fmt.Errorf("ddil harness: snapshot %s: %w", node.Name, err)
}
return InstanceSnapshot(xs), nil
}
// AssertPreserved fails t if any instance present in the pre-snapshot is
// absent from post, or if any instance regressed from a live state (pending,
// running) to a terminal state (shutting-down, stopped, terminated) without
// explicit scenario intent.
//
// EC2-style state ordering is used rather than the daemon's internal enum
// because /local/instances reports the same `state` string the gateway
// exposes over `ec2.DescribeInstances`. When daemon-local-autonomy §1a
// replaces LocalInstance with the daemon type, the stateOrdinal map below
// should be pointed at the authoritative constants.
func (pre InstanceSnapshot) AssertPreserved(t *testing.T, post InstanceSnapshot) {
t.Helper()
postByID := make(map[string]LocalInstance, len(post))
for _, p := range post {
postByID[p.InstanceID] = p
}
var missing []string
var regressed []string
for _, p := range pre {
q, ok := postByID[p.InstanceID]
if !ok {
missing = append(missing, p.InstanceID)
continue
}
if stateRegressed(p.State, q.State) {
regressed = append(regressed, fmt.Sprintf("%s: %s → %s", p.InstanceID, p.State, q.State))
}
}
if len(missing) == 0 && len(regressed) == 0 {
return
}
if len(missing) > 0 {
t.Errorf("ddil harness: instances disappeared from snapshot: %v", missing)
}
if len(regressed) > 0 {
t.Errorf("ddil harness: instances regressed to a terminal state: %v", regressed)
}
t.FailNow()
}
// stateOrdinal maps EC2 instance states to a monotonic lifecycle position.
// Unknown states return -1 so we can distinguish them from "pending" (0) and
// refuse to flag a regression we can't reason about.
func stateOrdinal(s string) int {
switch s {
case "pending":
return 0
case "running":
return 1
case "stopping":
return 2
case "stopped":
return 3
case "shutting-down":
return 4
case "terminated":
return 5
default:
return -1
}
}
// stateRegressed reports whether a live instance (pending/running) moved to
// a terminal state (stopping/stopped/shutting-down/terminated). Moves within
// the live band or within the terminal band are not flagged, and unknown
// states are treated as non-regressing so the assertion does not fail on
// schema drift.
func stateRegressed(pre, post string) bool {
preOrd := stateOrdinal(pre)
postOrd := stateOrdinal(post)
if preOrd < 0 || postOrd < 0 {
return false
}
return preOrd <= 1 && postOrd >= 2
}
+126
View File
@@ -0,0 +1,126 @@
//go:build e2e
package harness
import (
"bytes"
"context"
"fmt"
"net"
"os"
"time"
"golang.org/x/crypto/ssh"
)
// SSH is the transport scenarios use to run commands on a remote Node.
//
// It is an interface so dry-run mode and unit tests can stub it without
// opening real network connections. Run returns the command's combined
// stdout+stderr and a non-nil error if the remote command exited non-zero or
// the session failed.
type SSH interface {
Run(ctx context.Context, node Node, cmd string) ([]byte, error)
Close() error
}
// sshClient is the production SSH transport, backed by
// golang.org/x/crypto/ssh. One client is opened per (user, key, node) tuple
// on first use and cached for the harness lifetime.
type sshClient struct {
user string
signer ssh.Signer
conns map[string]*ssh.Client
connectDur time.Duration
runDur time.Duration
}
// NewSSH returns an SSH transport configured from a Cluster.
//
// The private key at cluster.SSHKeyPath must be in a format understood by
// ssh.ParsePrivateKey (OpenSSH or PEM). Host keys are not verified — the
// harness runs against short-lived test infrastructure where TOFU would add
// noise and no security benefit. Do not reuse this transport for production
// access.
func NewSSH(cluster *Cluster) (SSH, error) {
keyBytes, err := os.ReadFile(cluster.SSHKeyPath)
if err != nil {
return nil, fmt.Errorf("ddil harness: read ssh key %s: %w", cluster.SSHKeyPath, err)
}
signer, err := ssh.ParsePrivateKey(keyBytes)
if err != nil {
return nil, fmt.Errorf("ddil harness: parse ssh key %s: %w", cluster.SSHKeyPath, err)
}
return &sshClient{
user: cluster.SSHUser,
signer: signer,
conns: make(map[string]*ssh.Client),
connectDur: 10 * time.Second,
runDur: 2 * time.Minute,
}, nil
}
func (s *sshClient) dial(node Node) (*ssh.Client, error) {
if c, ok := s.conns[node.Addr]; ok {
return c, nil
}
cfg := &ssh.ClientConfig{
User: s.user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(s.signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // test harness, not production
Timeout: s.connectDur,
}
c, err := ssh.Dial("tcp", net.JoinHostPort(node.Addr, "22"), cfg)
if err != nil {
return nil, fmt.Errorf("ddil harness: ssh dial %s: %w", node.Addr, err)
}
s.conns[node.Addr] = c
return c, nil
}
func (s *sshClient) Run(ctx context.Context, node Node, cmd string) ([]byte, error) {
client, err := s.dial(node)
if err != nil {
return nil, err
}
session, err := client.NewSession()
if err != nil {
return nil, fmt.Errorf("ddil harness: ssh new session %s: %w", node.Name, err)
}
defer func() { _ = session.Close() }()
var out bytes.Buffer
session.Stdout = &out
session.Stderr = &out
// Propagate context cancellation by signalling the session. ssh.Session
// has no Kill; Signal(KILL) is the portable way to interrupt a running
// command.
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
_ = session.Signal(ssh.SIGKILL)
_ = session.Close()
case <-done:
}
}()
if err := session.Run(cmd); err != nil {
return out.Bytes(), fmt.Errorf("ddil harness: ssh run on %s (%q): %w\noutput: %s",
node.Name, cmd, err, out.String())
}
return out.Bytes(), nil
}
func (s *sshClient) Close() error {
var firstErr error
for addr, c := range s.conns {
if err := c.Close(); err != nil && firstErr == nil {
firstErr = fmt.Errorf("ddil harness: ssh close %s: %w", addr, err)
}
delete(s.conns, addr)
}
return firstErr
}
+33
View File
@@ -0,0 +1,33 @@
//go:build e2e
package harness
import (
"context"
"fmt"
"testing"
)
// LocalStateRevision reads the daemon's local state file revision via
// /local/status. The revision is a monotonic counter bumped on every write
// to the local state file (see daemon-local-autonomy §1a); AssertMonotonic
// uses it to prove that a fault did not cause the daemon to rewind or reset
// its persisted state.
func LocalStateRevision(ctx context.Context, d *DaemonClient, node Node) (uint64, error) {
s, err := d.Status(ctx, node)
if err != nil {
return 0, fmt.Errorf("ddil harness: local state revision %s: %w", node.Name, err)
}
return s.Revision, nil
}
// AssertMonotonic fails t if after is strictly less than before. Equal
// revisions are permitted because a scenario that exercises a read-only
// fault (e.g. NATS kill with no instance mutations) should see the revision
// unchanged.
func AssertMonotonic(t *testing.T, before, after uint64) {
t.Helper()
if after < before {
t.Fatalf("ddil harness: local state revision regressed: before=%d after=%d", before, after)
}
}
+44
View File
@@ -0,0 +1,44 @@
#cloud-config
# DDIL witness VM payload. Runs a monotonic counter-to-file loop so the
# harness can prove a workload kept making progress across a fault injection.
# See docs/development/improvements/ddil-e2e-test-harness.md §1 (witness helpers).
write_files:
- path: /usr/local/bin/witness-counter.sh
permissions: '0755'
owner: root:root
content: |
#!/bin/sh
# Resume from the last recorded value if the service restarts so the
# counter stays monotonic across daemon/VM restarts — AssertProgressed
# relies on the count never going backwards.
i=0
if [ -r /var/lib/counter ]; then
existing=$(cat /var/lib/counter 2>/dev/null || echo 0)
case "$existing" in
''|*[!0-9]*) i=0 ;;
*) i=$existing ;;
esac
fi
while :; do
echo "$i" > /var/lib/counter
i=$((i + 1))
sleep 1
done
- path: /etc/systemd/system/witness-counter.service
content: |
[Unit]
Description=DDIL witness counter
After=network.target
[Service]
Type=simple
ExecStartPre=/bin/sh -c 'mkdir -p /var/lib && touch /var/lib/counter'
ExecStart=/usr/local/bin/witness-counter.sh
Restart=always
RestartSec=1
[Install]
WantedBy=multi-user.target
runcmd:
- [systemctl, daemon-reload]
- [systemctl, enable, --now, witness-counter.service]
+420
View File
@@ -0,0 +1,420 @@
//go:build e2e
package harness
import (
"context"
"crypto/tls"
_ "embed"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ec2"
"golang.org/x/crypto/ssh"
)
// cloudInitWitness is the user-data payload that turns a fresh cloud-init VM
// into a monotonic counter. See testdata/cloud-init-witness.yaml for the
// source; embedded so the harness is self-contained.
//
//go:embed testdata/cloud-init-witness.yaml
var cloudInitWitness []byte
// gatewayPort is the AWS gateway port on each cluster node. Matches
// tests/e2e/run-multinode-e2e.sh's AWSGW_PORT.
const gatewayPort = 9999
// Witness bundles the AWS SDK client, guest/host SSH credentials, and AMI
// selection needed to launch and interrogate counter VMs. One Witness is
// shared across the scenarios in a suite.
type Witness struct {
ec2 *ec2.EC2
cluster *Cluster
ssh SSH
hostSigner ssh.Signer
guestSigner ssh.Signer
guestUser string
ami string
instanceType string
keyName string
}
// NewWitness constructs a Witness from environment-supplied settings:
//
// - AWS_REGION (required) — passed to the AWS SDK client.
// - DDIL_WITNESS_AMI (optional) — specific AMI ID; if unset, an
// ami-ubuntu-* image is discovered at launch time.
// - DDIL_WITNESS_INSTANCE_TYPE (default t2.micro) — instance type.
// - DDIL_WITNESS_KEY_NAME (default spinifex-key) — EC2 key pair name.
// - DDIL_GUEST_SSH_USER (default ubuntu) — user for guest SSH.
// - DDIL_GUEST_SSH_KEY (default cluster.SSHKeyPath) — private key for
// guest SSH; may point at the same key baked into the AMI.
//
// Credentials for the SDK come from the default chain (AWS_ACCESS_KEY_ID/
// AWS_SECRET_ACCESS_KEY or shared profile), matching the tofu-cluster
// convention used by the shell E2E suites.
func NewWitness(cluster *Cluster, transport SSH) (*Witness, error) {
region := os.Getenv("AWS_REGION")
if region == "" {
return nil, errors.New("ddil harness: NewWitness requires AWS_REGION")
}
if len(cluster.Nodes) == 0 {
return nil, errors.New("ddil harness: NewWitness requires a non-empty cluster")
}
endpoint := "https://" + net.JoinHostPort(cluster.Nodes[0].Addr, strconv.Itoa(gatewayPort))
sess, err := session.NewSession(&aws.Config{
Region: aws.String(region),
Endpoint: aws.String(endpoint),
HTTPClient: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // self-signed test certs
},
},
})
if err != nil {
return nil, fmt.Errorf("ddil harness: aws session: %w", err)
}
hostSigner, err := loadSigner(cluster.SSHKeyPath)
if err != nil {
return nil, fmt.Errorf("ddil harness: host ssh key: %w", err)
}
guestKeyPath := envDefault("DDIL_GUEST_SSH_KEY", cluster.SSHKeyPath)
guestSigner, err := loadSigner(guestKeyPath)
if err != nil {
return nil, fmt.Errorf("ddil harness: guest ssh key: %w", err)
}
return &Witness{
ec2: ec2.New(sess),
cluster: cluster,
ssh: transport,
hostSigner: hostSigner,
guestSigner: guestSigner,
guestUser: envDefault("DDIL_GUEST_SSH_USER", "ubuntu"),
ami: os.Getenv("DDIL_WITNESS_AMI"),
instanceType: envDefault("DDIL_WITNESS_INSTANCE_TYPE", "t2.micro"),
keyName: envDefault("DDIL_WITNESS_KEY_NAME", "spinifex-key"),
}, nil
}
// WitnessVM is the live descriptor for a single counter VM. Created by
// LaunchWitnessVM; subsequent ReadCounter / AssertProgressed calls use the
// back-reference to the owning Witness to avoid passing the SDK/SSH deps
// through every call site.
type WitnessVM struct {
InstanceID string
HostNode Node // cluster node the VM's QEMU process lives on
SSHPort int // QEMU hostfwd port on HostNode mapped to guest :22
LaunchedAt time.Time
BaselineCounter int
w *Witness
}
// LaunchWitnessVM launches a counter VM and waits for its QEMU process to
// appear on host. The returned WitnessVM carries the host/port it was placed
// on (which may differ from host if the scheduler rejected the hint — a
// best-effort retry up to maxPlacementAttempts is performed before giving up).
//
// Depends on ami-ubuntu-* being registered in the cluster (the tofu-cluster
// bootstrap handles this) and on the AWS credential chain resolving, so
// scenarios gate on t.Skip when neither is available.
func LaunchWitnessVM(ctx context.Context, w *Witness, host Node) (*WitnessVM, error) {
const maxPlacementAttempts = 3
ami, err := w.resolveAMI(ctx)
if err != nil {
return nil, err
}
userData := base64.StdEncoding.EncodeToString(cloudInitWitness)
var lastErr error
for attempt := 1; attempt <= maxPlacementAttempts; attempt++ {
out, err := w.ec2.RunInstancesWithContext(ctx, &ec2.RunInstancesInput{
ImageId: aws.String(ami),
InstanceType: aws.String(w.instanceType),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
KeyName: aws.String(w.keyName),
UserData: aws.String(userData),
})
if err != nil {
return nil, fmt.Errorf("ddil harness: RunInstances: %w", err)
}
if len(out.Instances) == 0 || out.Instances[0].InstanceId == nil {
return nil, errors.New("ddil harness: RunInstances returned no instance")
}
id := aws.StringValue(out.Instances[0].InstanceId)
if err := w.waitForRunning(ctx, id, 2*time.Minute); err != nil {
_ = w.terminate(ctx, id)
return nil, err
}
placed, port, err := w.findHost(ctx, id)
if err != nil {
_ = w.terminate(ctx, id)
return nil, err
}
if placed.Index == host.Index {
baseline, err := readCounterViaTunnel(ctx, w, placed, port)
if err != nil {
// The counter service may not have started yet on a freshly
// booted guest; a zero baseline is the expected steady state
// since the service seeds /var/lib/counter with 0 on first
// ExecStartPre. Treat a connection failure as fatal so
// scenarios don't silently skip the progress assertion.
_ = w.terminate(ctx, id)
return nil, fmt.Errorf("ddil harness: witness baseline read: %w", err)
}
return &WitnessVM{
InstanceID: id,
HostNode: placed,
SSHPort: port,
LaunchedAt: time.Now(),
BaselineCounter: baseline,
w: w,
}, nil
}
lastErr = fmt.Errorf("witness landed on %s, wanted %s", placed.Name, host.Name)
_ = w.terminate(ctx, id)
}
return nil, fmt.Errorf("ddil harness: LaunchWitnessVM: %w after %d attempts", lastErr, maxPlacementAttempts)
}
// ReadCounter SSHes into the guest via its host's QEMU hostfwd port and
// returns the current /var/lib/counter value.
func (v *WitnessVM) ReadCounter(ctx context.Context) (int, error) {
return readCounterViaTunnel(ctx, v.w, v.HostNode, v.SSHPort)
}
// 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.
func AssertProgressed(ctx context.Context, t *testing.T, v *WitnessVM) {
t.Helper()
current, err := v.ReadCounter(ctx)
if err != nil {
t.Fatalf("ddil harness: read witness counter on %s (%s): %v", v.HostNode.Name, v.InstanceID, err)
}
if current <= v.BaselineCounter {
t.Fatalf("ddil harness: witness %s on %s did not progress: baseline=%d current=%d",
v.InstanceID, v.HostNode.Name, v.BaselineCounter, current)
}
t.Logf("witness %s on %s progressed %d → %d", v.InstanceID, v.HostNode.Name, v.BaselineCounter, current)
}
// --- internals ------------------------------------------------------------
func (w *Witness) resolveAMI(ctx context.Context) (string, error) {
if w.ami != "" {
return w.ami, nil
}
out, err := w.ec2.DescribeImagesWithContext(ctx, &ec2.DescribeImagesInput{
Filters: []*ec2.Filter{{
Name: aws.String("name"),
Values: []*string{aws.String("ami-ubuntu-*")},
}},
})
if err != nil {
return "", fmt.Errorf("ddil harness: DescribeImages: %w", err)
}
if len(out.Images) == 0 || out.Images[0].ImageId == nil {
return "", errors.New("ddil harness: no ami-ubuntu-* images registered in the cluster")
}
w.ami = aws.StringValue(out.Images[0].ImageId)
return w.ami, nil
}
func (w *Witness) waitForRunning(ctx context.Context, id string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
const interval = 2 * time.Second
for {
if ctx.Err() != nil {
return ctx.Err()
}
out, err := w.ec2.DescribeInstancesWithContext(ctx, &ec2.DescribeInstancesInput{
InstanceIds: []*string{aws.String(id)},
})
if err == nil {
for _, r := range out.Reservations {
for _, inst := range r.Instances {
if inst.State != nil && aws.StringValue(inst.State.Name) == "running" {
return nil
}
}
}
}
if time.Now().After(deadline) {
return fmt.Errorf("ddil harness: instance %s did not reach running within %s", id, timeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(interval):
}
}
}
var hostfwdPortRE = regexp.MustCompile(`hostfwd=tcp:[^:]*:(\d+)-:22`)
// findHost walks every cluster node looking for the QEMU process that owns
// instanceID. Returns the hosting node and the SSH hostfwd port extracted
// from its command line. Matches the shell helper pattern in
// run-multinode-e2e.sh:163-180.
func (w *Witness) findHost(ctx context.Context, instanceID string) (Node, int, error) {
cmd := fmt.Sprintf("ps auxw | grep %s | grep qemu-system | grep -v grep", shellQuote(instanceID))
// Give the daemon a short window to actually spawn QEMU after the
// EC2 state flip to running, since /aws/ec2 reports "running" before
// the daemon has finished forking the process on some nodes.
deadline := time.Now().Add(30 * time.Second)
const interval = 1 * time.Second
for {
for _, n := range w.cluster.Nodes {
out, err := w.ssh.Run(ctx, n, cmd)
if err != nil {
// `grep | grep` exits 1 when no match — our SSH wrapper treats
// that as error; try the next node rather than bailing.
continue
}
m := hostfwdPortRE.FindStringSubmatch(string(out))
if len(m) == 2 {
port, err := strconv.Atoi(m[1])
if err == nil {
return n, port, nil
}
}
}
if time.Now().After(deadline) {
return Node{}, 0, fmt.Errorf("ddil harness: could not locate QEMU host for %s across %d nodes", instanceID, len(w.cluster.Nodes))
}
select {
case <-ctx.Done():
return Node{}, 0, ctx.Err()
case <-time.After(interval):
}
}
}
func (w *Witness) terminate(ctx context.Context, id string) error {
_, err := w.ec2.TerminateInstancesWithContext(ctx, &ec2.TerminateInstancesInput{
InstanceIds: []*string{aws.String(id)},
})
if err != nil {
return fmt.Errorf("ddil harness: TerminateInstances %s: %w", id, err)
}
return nil
}
// readCounterViaTunnel opens an SSH connection to host, tunnels a TCP
// channel to 127.0.0.1:port (QEMU's guest hostfwd), runs a fresh SSH
// handshake over that channel using the guest credentials, and reads
// /var/lib/counter.
//
// The hostfwd target is 127.0.0.1 because the daemon binds QEMU's user-mode
// networking to loopback; the orchestrator cannot reach it directly without
// the host SSH relay.
func readCounterViaTunnel(ctx context.Context, w *Witness, host Node, port int) (int, error) {
hostClient, err := dialHost(ctx, host, w.cluster.SSHUser, w.hostSigner)
if err != nil {
return 0, err
}
defer func() { _ = hostClient.Close() }()
guestAddr := net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
tunnel, err := hostClient.DialContext(ctx, "tcp", guestAddr)
if err != nil {
return 0, fmt.Errorf("ddil harness: tunnel %s → %s: %w", host.Name, guestAddr, err)
}
guestCfg := &ssh.ClientConfig{
User: w.guestUser,
Auth: []ssh.AuthMethod{ssh.PublicKeys(w.guestSigner)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // ephemeral test VM
Timeout: 10 * time.Second,
}
conn, chans, reqs, err := ssh.NewClientConn(tunnel, guestAddr, guestCfg)
if err != nil {
_ = tunnel.Close()
return 0, fmt.Errorf("ddil harness: guest ssh handshake on %s: %w", guestAddr, err)
}
guestClient := ssh.NewClient(conn, chans, reqs)
defer func() { _ = guestClient.Close() }()
session, err := guestClient.NewSession()
if err != nil {
return 0, fmt.Errorf("ddil harness: guest ssh session: %w", err)
}
defer func() { _ = session.Close() }()
raw, err := session.CombinedOutput("cat /var/lib/counter")
if err != nil {
return 0, fmt.Errorf("ddil harness: read /var/lib/counter: %w (output: %s)", err, strings.TrimSpace(string(raw)))
}
s := strings.TrimSpace(string(raw))
if s == "" {
return 0, nil
}
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("ddil harness: parse /var/lib/counter %q: %w", s, err)
}
return n, nil
}
func dialHost(ctx context.Context, node Node, user string, signer ssh.Signer) (*ssh.Client, error) {
cfg := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec // tofu-cluster test hosts
Timeout: 10 * time.Second,
}
d := net.Dialer{Timeout: cfg.Timeout}
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(node.Addr, "22"))
if err != nil {
return nil, fmt.Errorf("ddil harness: dial host %s: %w", node.Name, err)
}
c, chans, reqs, err := ssh.NewClientConn(conn, node.Addr, cfg)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("ddil harness: host ssh handshake %s: %w", node.Name, err)
}
return ssh.NewClient(c, chans, reqs), nil
}
func loadSigner(path string) (ssh.Signer, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
s, err := ssh.ParsePrivateKey(b)
if err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return s, nil
}
func envDefault(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
@@ -0,0 +1,14 @@
//go:build e2e
package scenarios
import "testing"
// TestScenarioA_NATSKill — stop spinifex-nats on a single node without
// touching spinifex-daemon, verify the daemon stays up in standalone
// mode, serves its local API, and the surviving 2-node quorum keeps
// 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")
}
@@ -0,0 +1,14 @@
//go:build e2e
package scenarios
import "testing"
// 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.
func TestScenarioB_DaemonRestartWithoutNATS(t *testing.T) {
scenarioSkip(t, "B", "daemon-local-autonomy §1a/1d")
}
@@ -0,0 +1,14 @@
//go:build e2e
package scenarios
import "testing"
// TestScenarioC_CleanPartition — iptables-DROP node3 away from node1 and
// node2, verify the majority keeps serving API, the isolated node
// reports standalone mode, and heal converges state without duplicate
// 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")
}
@@ -0,0 +1,169 @@
//go:build e2e
package scenarios
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
)
// scenarioFuncRe matches the TestScenario<L>_<Name> functions that make
// up the DDIL suite. The capture group is the single-letter identifier
// (A..F) used everywhere in the plan, bead, and TEST_COVERAGE.md.
var scenarioFuncRe = regexp.MustCompile(`^TestScenario([A-Z])_[A-Za-z0-9]+$`)
// coverageDocRe matches the scenario-letter column in the DDIL table in
// TEST_COVERAGE.md (e.g. `| A | ...`). Anchoring on a leading pipe +
// whitespace keeps the pattern from matching prose mentions of "A" or
// "B" elsewhere in the doc.
var coverageDocRe = regexp.MustCompile(`(?m)^\|\s*([A-F])\s*\|`)
// TestCoverageDrift enforces TEST_COVERAGE.md and the scenarios package
// stay in lock-step. It fails if either:
// - A TestScenario<L>_... function exists in the scenarios package but
// TEST_COVERAGE.md's DDIL table has no row for letter <L>.
// - TEST_COVERAGE.md's DDIL table has a row for letter <L> but no
// TestScenario<L>_... function exists.
//
// Drift surfaces the most common mistake in rolling out Phase 2/3
// hardening: flipping a scenario's Skip to a real assertion without
// updating the coverage table (or vice versa).
//
// Runs unconditionally — including under DDIL_DRY_RUN=1 — because it
// only reads local source and has no cluster dependency.
func TestCoverageDrift(t *testing.T) {
scenarioDir := callerDir(t)
codeLetters, err := lettersFromSource(scenarioDir)
if err != nil {
t.Fatalf("extract scenario letters from source: %v", err)
}
if len(codeLetters) == 0 {
t.Fatalf("no TestScenario<L>_... functions found in %s — coverage drift check is meaningful only with scenarios present", scenarioDir)
}
docPath := filepath.Join(scenarioDir, "..", "..", "TEST_COVERAGE.md")
docLetters, err := lettersFromDoc(docPath)
if err != nil {
t.Fatalf("extract scenario letters from %s: %v", docPath, err)
}
missing := setDiff(codeLetters, docLetters)
extra := setDiff(docLetters, codeLetters)
if len(missing) == 0 && len(extra) == 0 {
return
}
if len(missing) > 0 {
t.Errorf("scenarios present in code but missing from %s DDIL table: %v", filepath.Base(docPath), missing)
}
if len(extra) > 0 {
t.Errorf("scenarios present in %s DDIL table but missing TestScenario<L>_... in code: %v", filepath.Base(docPath), extra)
}
}
// callerDir returns the directory holding this test file. Used to locate
// sibling *_test.go files and to compute the relative path to
// TEST_COVERAGE.md without hard-coding a fragile relative path at the
// test-binary CWD.
func callerDir(t *testing.T) string {
t.Helper()
_, self, _, ok := runtime.Caller(0)
if !ok {
t.Fatalf("runtime.Caller(0) failed — cannot locate scenarios dir")
}
return filepath.Dir(self)
}
// lettersFromSource AST-parses every *_test.go file in dir (except this
// file and main_test.go, which hold no scenarios) and returns the
// deduplicated sorted set of scenario letters declared by top-level
// TestScenario<L>_... functions.
func lettersFromSource(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
fset := token.NewFileSet()
seen := make(map[string]struct{})
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, "_test.go") {
continue
}
if name == "coverage_drift_test.go" || name == "main_test.go" {
continue
}
path := filepath.Join(dir, name)
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if err != nil {
return nil, err
}
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Recv != nil {
continue
}
m := scenarioFuncRe.FindStringSubmatch(fn.Name.Name)
if m == nil {
continue
}
seen[m[1]] = struct{}{}
}
}
return sortedKeys(seen), nil
}
// lettersFromDoc reads TEST_COVERAGE.md and returns the deduplicated
// sorted set of scenario letters its DDIL table declares. Rows are
// matched by coverageDocRe so prose mentions elsewhere in the file do
// not trigger false positives.
func lettersFromDoc(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
seen := make(map[string]struct{})
for _, m := range coverageDocRe.FindAllStringSubmatch(string(data), -1) {
seen[m[1]] = struct{}{}
}
return sortedKeys(seen), nil
}
func sortedKeys(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// setDiff returns elements in a that are not in b. Both inputs must be
// sorted (they always are, coming from sortedKeys); the linear merge
// keeps the drift check O(n+m) rather than O(nm).
func setDiff(a, b []string) []string {
var out []string
i, j := 0, 0
for i < len(a) && j < len(b) {
switch {
case a[i] < b[j]:
out = append(out, a[i])
i++
case a[i] > b[j]:
j++
default:
i++
j++
}
}
out = append(out, a[i:]...)
return out
}
@@ -0,0 +1,13 @@
//go:build e2e
package scenarios
import "testing"
// TestScenarioD_DegradedLink — apply the SATCOM netem profile to every
// node, verify fan-out queries return complete results and Raft does not
// enter continuous leader election. See
// docs/development/improvements/ddil-e2e-test-harness.md §3 Scenario D.
func TestScenarioD_DegradedLink(t *testing.T) {
scenarioSkip(t, "D", "ddil-concept-demonstrator §1.2 + predastore-ddil-hardening §1")
}
@@ -0,0 +1,14 @@
//go:build e2e
package scenarios
import "testing"
// TestScenarioE_PredastoreWriteUnderPartition — partition node3, put a
// 10MB S3 object from the majority side, verify PUT succeeds with the
// missing-shard repair journal capturing node3's parity, then heal and
// verify journal drain + cross-node reads. See
// docs/development/improvements/ddil-e2e-test-harness.md §3 Scenario E.
func TestScenarioE_PredastoreWriteUnderPartition(t *testing.T) {
scenarioSkip(t, "E", "predastore-ddil-hardening §2")
}
@@ -0,0 +1,13 @@
//go:build e2e
package scenarios
import "testing"
// TestScenarioF_RaftUnderSATCOM — apply the SATCOM netem profile
// cluster-wide and verify Raft leader elections remain ≤1 over a
// 5-minute window (baselined against LAN, where elections thrash). See
// docs/development/improvements/ddil-e2e-test-harness.md §3 Scenario F.
func TestScenarioF_RaftUnderSATCOM(t *testing.T) {
scenarioSkip(t, "F", "predastore-ddil-hardening §1")
}
+165
View File
@@ -0,0 +1,165 @@
//go:build e2e
// Package scenarios holds the DDIL E2E scenario tests. Each scenario
// (A..F) exercises one failure mode documented in
// docs/development/improvements/ddil-e2e-test-harness.md. Scenarios start
// as t.Skip stubs and are flipped to real assertions by the hardening
// epics that make them runnable (daemon-local-autonomy,
// predastore-ddil-hardening).
package scenarios
import (
"context"
"log"
"os"
"os/signal"
"sync"
"syscall"
"testing"
"time"
"github.com/mulgadc/spinifex/tests/e2e/ddil/harness"
)
// scenarioLetters is the canonical ordered list of scenarios the suite
// knows about. It is used for DDIL_DRY_RUN=1 logging and by
// TestCoverageDrift as the source-of-truth set when cross-checking
// TEST_COVERAGE.md. Keep in sync with the TestScenario<L>_* functions in
// this package — TestCoverageDrift fails loudly if it drifts.
var scenarioLetters = []string{"A", "B", "C", "D", "E", "F"}
// Package-level state shared between TestMain's signal handler and
// TestHarnessSmoke. Populated by setupCluster when DDIL_NODES is set and
// DDIL_DRY_RUN is unset; both remain nil otherwise.
var (
clusterOnce sync.Once
cluster *harness.Cluster
sshXport harness.SSH
clusterErr error
)
// dryRun reports whether DDIL_DRY_RUN=1 is set. Scenarios and
// TestHarnessSmoke skip cluster-touching work in dry-run mode.
func dryRun() bool { return os.Getenv("DDIL_DRY_RUN") == "1" }
// setupCluster builds the Cluster/SSH pair once per test binary. Callers
// that need cluster access gate on both (cluster, err): cluster is nil in
// dry-run or when DDIL_NODES is unset.
func setupCluster() (*harness.Cluster, harness.SSH, error) {
clusterOnce.Do(func() {
if dryRun() {
return
}
if os.Getenv("DDIL_NODES") == "" {
return
}
c, err := harness.ClusterFromEnv()
if err != nil {
clusterErr = err
return
}
s, err := harness.NewSSH(c)
if err != nil {
clusterErr = err
return
}
cluster = c
sshXport = s
})
return cluster, sshXport, clusterErr
}
// TestMain is the suite-level entry point. It installs a signal handler
// that best-effort resets the cluster on SIGINT/SIGTERM so an aborted CI
// run does not leave iptables DROP rules or tc netem qdiscs in place,
// then delegates to go test's default runner. DDIL_DRY_RUN=1 logs the
// planned scenarios and skips cluster initialisation, leaving
// TestCoverageDrift as the only meaningful assertion.
func TestMain(m *testing.M) {
if dryRun() {
log.Printf("ddil: DDIL_DRY_RUN=1 — planned scenarios: %v (TestCoverageDrift will still execute)", scenarioLetters)
}
c, s, err := setupCluster()
if err != nil {
log.Printf("ddil: cluster setup failed (continuing, scenarios will skip): %v", err)
}
stop := installSignalHandler(c, s)
defer stop()
code := m.Run()
if s != nil {
_ = s.Close()
}
os.Exit(code)
}
// installSignalHandler traps SIGINT/SIGTERM and, when a cluster is
// available, calls ResetAllNodes before exiting non-zero. Without a
// cluster (dry-run or missing env) it still exits non-zero so the parent
// shell sees the signal. Returns a stop function the caller must defer to
// unregister the handler.
func installSignalHandler(c *harness.Cluster, s harness.SSH) func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
done := make(chan struct{})
go func() {
select {
case sig := <-ch:
log.Printf("ddil: received %s, cleaning up before exit", sig)
if c != nil && s != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
if err := harness.ResetAllNodes(ctx, c, s); err != nil {
log.Printf("ddil: ResetAllNodes on signal: %v", err)
}
_ = s.Close()
}
os.Exit(1)
case <-done:
return
}
}()
return func() {
signal.Stop(ch)
close(done)
}
}
// TestHarnessSmoke exercises the reset + clean-state round trip without
// running a scenario. It satisfies the Phase 1 acceptance criterion that
// "full run against real cluster completes with AssertCleanState +
// ResetAllNodes round-tripping cleanly" and gives hardening PR authors a
// fast command for validating their tofu-cluster before touching
// scenarios (go test -run TestHarnessSmoke).
func TestHarnessSmoke(t *testing.T) {
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 (harness smoke requires a live cluster)")
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := harness.ResetAllNodes(ctx, c, s); err != nil {
t.Fatalf("ResetAllNodes: %v", err)
}
harness.AssertCleanState(ctx, t, c, s)
}
// scenarioSkip is the shared skip helper used by each TestScenario<L>_*
// stub. Centralising the message format keeps Phase 2/3 hardening PRs to
// a single place when they flip a scenario: delete the scenarioSkip call,
// add the real body.
func scenarioSkip(t *testing.T, letter, dep string) {
t.Helper()
t.Skipf("scenario %s requires %s", letter, dep)
}