Files
spinifex/scripts/test-ddil-phase1.sh
Josh Siviour 6c54a6790c feat(daemon): DDIL Tier 1 — daemon-local-autonomy phase 1 (#204)
* refactor(daemon): extract Describe instance handlers to service (1b-pre phase 1)

Collapse the Describe family in daemon_handlers_instance.go to thin Pattern A
wrappers over a transport-neutral InstanceServiceImpl. This brings the read
path in line with the rest of the codebase and is the prerequisite for 1b's
local HTTP API (mulga-siv-14, daemon-local-autonomy plan).

Extracted: DescribeInstances, DescribeInstanceTypes, DescribeInstanceAttribute,
DescribeStoppedInstances, DescribeTerminatedInstances. Filter helpers
(instanceMatchesFilters, matchTagKey/Value, DescribeInstancesValidFilters,
IsInstanceVisible) move to the instance package alongside the service methods.

InstanceService gains the new methods and two dep interfaces
(ResourceCapacityProvider, StoppedInstanceStore) so the daemon's ResourceManager
and JetStreamManager can plug in without leaking concrete types. NATSInstanceService
returns a clear error for the fan-out describes (DescribeInstances,
DescribeInstanceTypes) since cluster-wide aggregation lives in the gateway's
scatter-gather helpers, not a single-node NATS request.

Behaviour changes: malformed JSON on DescribeInstanceAttribute now responds
with ValidationError (matching every other Pattern A handler) instead of
ServerInternal. Test updated.

Mutators (RunInstances, ModifyInstanceAttribute, StartStoppedInstance,
TerminateStoppedInstance, sub-handlers) remain Pattern B for now; follow-up
phases of mulga-siv-14 will collapse them.

* feat(daemon): local-first instance state persistence (1a)

Make /var/lib/spinifex/state/instance-state.json the source of truth for a
node's VMs; JetStream KV becomes a best-effort cluster cache. The daemon now
survives KV unavailability without losing instance state.

- New local_state.go: atomic write (tmp+fsync+rename), schema_version gate,
  missing-file = fresh install, corrupt = refuse start.
- Daemon.WriteState writes the local file under Instances.Mu, then calls
  jsManager.WriteStateBestEffort with a 1s timeout. KV failures log and
  continue; clients never see "KV down" during partition.
- Daemon.LoadState reads from the local file only — no KV fallback (a fresh
  node has empty KV anyway, so silent fallback would mask bugs).
- restoreInstances now returns error and Start() aborts on corrupt local
  state rather than silently starting empty.
- jsManager.WriteState no longer self-locks (caller holds Mu); added
  WriteStateBestEffort and shared marshalInstanceState helper.

Reconnect-driven KV resync deferred to siv-16 (1c owns the callback wiring).

Bead: mulga-siv-15
Plan: docs/development/feature/daemon-local-state-persistence.md

* fix(daemon): root local state at DataDir not BaseDir

LocalStatePath was falling back to BaseDir when DataDir was empty, putting
the file at /var/lib/spinifex/spinifex/state/instance-state.json (under the
daemon's own subdir) instead of the documented
/var/lib/spinifex/state/instance-state.json (the platform-shared root).

Drop the BaseDir fallback. Empty DataDir resolves to DefaultDataDir
(/var/lib/spinifex). Tests now set DataDir alongside BaseDir on their config
fixtures.

* feat(daemon): graceful NATS disconnect/reconnect handling (1c)

Daemon now flips a `mode` field (cluster|standalone) on NATS
disconnect/reconnect via optional callback hooks added to
utils.ConnectNATS. Reconnect bumps natsRetryCount and triggers 1a's
full local-state push to KV. NATSRequest/NATSScatterGather fail fast
with ErrClusterUnavailable when the connection is down. Gateway
short-circuits NATS-bound requests with a 503 ServiceUnavailable
carrying "cluster unavailable: NATS disconnected — check daemon
/local/status" so AWS clients see the failure mode immediately
instead of hanging on per-call timeouts.

Plan: docs/development/feature/daemon-autonomy-1c-disconnect.md

* fix(gateway): SigV4 middleware short-circuits on NATS disconnect

The 1c gateway.Request() IsConnected check (gateway.go:234) was
unreachable for any authenticated path because SigV4 auth middleware
runs first and calls IAMService.LookupAccessKey over JetStream KV. With
NATS down the lookup hangs ~5s on context.DeadlineExceeded and the
gateway returns 500 InternalError. AWS SDK retries it twice for ~18s of
user-visible latency instead of the <100ms 503 promised by the 1c plan.

Mirror the IsConnected check at the top of SigV4AuthMiddleware (after
credential parse so malformed auth headers still get the right SigV4
error code, before the IAM lookup). The catch-all in gateway.Request()
stays as defence-in-depth for any future unauthed paths.

Coverage debt picked up alongside: 1c utils.NATSRequest /
NATSScatterGather fail-fast paths, daemon Mode/onNATSDisconnect/
onNATSReconnect/localStatePath/Write/LoadState branches, and 1b-pre
InstanceServiceImpl Describe* methods (partial siv-22).

Bead: mulga-siv-23

* feat(daemon): read-only /local/* HTTP API (1b)

Wires GET /local/instances, GET /local/instances/{id}, GET /local/status
onto the existing ClusterManager TLS listener so observers can read this
node's instance state and connectivity mode while NATS is unreachable.

Also fixes a pre-existing race in WriteState: json.Marshal walked shared
*vm.VM pointers from SnapshotMap while concurrent TransitionState calls
mutated VM fields under the manager lock. Marshal now runs inside
vmMgr.View; WriteLocalStateBytes / WriteStateBytesBestEffort accept the
pre-marshalled JSON and commit lock-free. TestTransitionState_Concurrent
Transitions is race-clean across 30 iterations.

Adds Daemon.stateRevision (atomic.Uint64) bumped on every successful
local-state write, surfaced as /local/status.revision so observers can
detect changes without diffing payloads.

* feat(daemon): two-phase startup — survive NATS down (1d)

Split daemon Start() into startLocal() (HTTPS, mgmt bridge, OVS plumber,
OOM, local state load — no NATS) and startCluster() (background, infinite
retry exp backoff cap 60s for NATS connect, then JetStream + services +
restoreInstances + subscribeAll + heartbeat). Daemon never process-exits
on NATS failure; instead it stays up in standalone mode and reconnects in
the background.

Drop ExecStartPre=wait-for-nats.sh from spinifex-daemon.service — local
bootstrap no longer requires NATS to be reachable.

ConnectNATSWithRetry now supports infinite retry (WithMaxWait(0)),
context cancellation (WithContext), per-attempt error callback for the
nats_retry_count operator signal on /local/status, and a configurable
backoff cap (WithMaxRetryDelay).

bead: mulga-siv-17 (closes)

* feat(daemon): Tier 1 invariant guard for cluster-scoped service init (1e-audit)

Make the DDIL §1e-audit invariant explicit: startLocal must not initialise any
NATS-dependent or JetStream-KV-backed handle. Adds assertNoClusterServicesInitialised
as a runtime guard at the end of startLocal that fails fast if a future edit
hoists a cluster-scoped service into Phase 1, plus comment blocks naming the
invariant on both startLocal and startCluster.

Code audit confirms the existing 1d split already keeps every cluster-scoped
bucket out of Phase 1; this change pins the property so it cannot regress
silently. Tier table for the 25 daemon-process KV buckets is finalised in
docs/development/feature/daemon-local-autonomy.md §1e-audit.

bead: mulga-siv-19 (closes)

* feat(daemon): NATS observability hardening (1d-obs)

Closes the residual visibility gap left by the 1d non-blocking startup:

- Extend /local/status with kv_sync_failures, last_kv_sync_at,
  last_kv_sync_error so an operator polling the local API sees prolonged
  divergence between the local file and JetStream KV.
- Add a KVSyncObserver interface on JetStreamManager so the daemon can
  count best-effort write outcomes without coupling the manager to
  daemon state.
- Escalate utils.ConnectNATSWithRetry per-attempt log to slog.Error
  (rate-limited to once per minute, with disconnected_for context) once
  attempt count crosses the natsRetryEscalateAttempt threshold (~30
  attempts ≈ ~30 min disconnected at the 60s backoff cap), so a stuck
  NATS config surfaces in error logs instead of routine warn noise.

Prometheus metrics export deferred until a system-wide telemetry plan
(likely OTel) lands; the structured /local/status fields plus the
escalated retry log cover the Tier 1 visibility gap.

* feat(daemon): opt-in SPINIFEX_REQUIRE_NATS strict-startup mode (1d-strict)

When SPINIFEX_REQUIRE_NATS=1, startCluster's first NATS connect uses a
bounded ~30s retry and the daemon aborts on timeout. Default behaviour
(env unset) keeps the Tier 1 infinite-retry path so prod / edge nodes
still survive a missing NATS at boot. Restores pre-DDIL fail-fast UX
for dev / test / single-node deploys without flipping the prod default.

mulga-siv-44.

* test(scripts): systemd-level smoke for DDIL phase 1

Bash harness exercising daemon-local-autonomy on a real `make deploy` node:
baseline cluster mode → kill spinifex-nats → assert standalone + retry
counter advance + /local/instances served → restart NATS → assert mode flip
+ KV resync stamp → SPINIFEX_REQUIRE_NATS=1 bounded-exit. Cleanup trap
restores both services. Complements existing unit coverage by validating
real systemd wiring on port 8443.

* fix(test-ddil): daemon ClusterManager binds 4432 not 8443

8443 is predastore. Daemon's HTTPS ClusterManager (where /local/* lives)
binds [nodes.node1.daemon].host = 0.0.0.0:4432 in the deployed config.
Plan doc daemon-local-autonomy.md:112 incorrectly said 8443; actual code
uses d.config.Daemon.Host.

* fix(test-ddil): correct retry-counter site + bypass NATS Wants= for strict test

Two bugs in the v1 script:

1) nats_retry_count assertion ran during disconnect — but the counter
   only bumps on disconnect→reconnect *cycles* (daemon.go:321,
   onNATSReconnect). Moved assertion to step 3 (restart NATS) where
   the reconnect callback fires.

2) Step 4 stopped spinifex-nats then ran 'systemctl restart
   spinifex-daemon' — but the daemon unit's Wants=spinifex-nats.service
   re-pulled NATS up via the Wants= chain, so the strict-mode 30s
   timeout never fired. systemctl mask refuses on /etc-installed
   units, and --runtime mask is shadowed by /etc. Block via a runtime
   drop-in with ConditionPathExists=! pointing at a sentinel file.

Verified: all 5 checks pass on a single-node make deploy box; daemon
exits 32s after restart with SPINIFEX_REQUIRE_NATS=1 + NATS blocked.

* fix(admin): render data_dir into spinifex.toml so non-root nodes write local state

Pseudo-multinode E2E (rootless tf-user, ~/nodeN data roots) hit
"mkdir /var/lib/spinifex/state: permission denied" on every RunInstances:
Config.DataDir was empty because the template never emitted a data_dir
key, and LocalStatePath fell back to DefaultDataDir (/var/lib/spinifex).

Surfaced after 8eb9c5fa dropped the BaseDir fallback in LocalStatePath —
previously SPINIFEX_BASE_DIR=$DATA_DIR/spinifex/ (set by start-dev.sh)
masked the missing data_dir.

ConfigSettings.DataDir is already populated to spxRoot at admin
init/join; render it under [nodes.X] so it actually reaches the daemon.
2026-05-10 10:08:12 +10:00

122 lines
4.9 KiB
Bash
Executable File

#!/bin/bash
# test-ddil-phase1.sh — systemd-level smoke test for daemon-local-autonomy
# Tier 1 (1a / 1b / 1c / 1d / 1d-strict). Run after `make deploy` on a single
# node where spinifex-daemon and spinifex-nats are managed by systemd.
#
# Usage: scripts/test-ddil-phase1.sh
# Requires: sudo, curl, jq
#
# Destructive on the local node — stops/starts spinifex-nats and restarts
# spinifex-daemon. Do NOT run against a host carrying workloads. The cleanup
# trap restores both services on exit.
set -euo pipefail
DAEMON_URL="${DAEMON_URL:-https://127.0.0.1:4432}"
DAEMON_SVC=spinifex-daemon
NATS_SVC=spinifex-nats
OVERRIDE=/etc/systemd/system/${DAEMON_SVC}.service.d/99-test-ddil.conf
NATS_BLOCK=/run/spinifex-nats-blocked
NATS_BLOCK_DROPIN=/run/systemd/system/${NATS_SVC}.service.d/99-test-block.conf
CURL=(curl -fsS -k --max-time 5)
ok() { printf '\033[32mPASS\033[0m %s\n' "$1"; }
fail() { printf '\033[31mFAIL\033[0m %s\n' "$1"; exit 1; }
info() { printf '\033[36m==>\033[0m %s\n' "$1"; }
cleanup() {
info "cleanup: removing nats block, override, restoring services"
sudo rm -f "$NATS_BLOCK" "$NATS_BLOCK_DROPIN" "$OVERRIDE"
sudo rmdir "$(dirname "$NATS_BLOCK_DROPIN")" 2>/dev/null || true
sudo systemctl daemon-reload
sudo systemctl start "$NATS_SVC" 2>/dev/null || true
sudo systemctl restart "$DAEMON_SVC" 2>/dev/null || true
}
trap cleanup EXIT
field() { "${CURL[@]}" "${DAEMON_URL}/local/status" | jq -r ".$1 // empty"; }
wait_field() {
local f=$1 want=$2 timeout=${3:-30} elapsed=0
while [ "$elapsed" -lt "$timeout" ]; do
[ "$(field "$f" 2>/dev/null || true)" = "$want" ] && return 0
sleep 1; elapsed=$((elapsed + 1))
done
return 1
}
# 0. baseline
info "baseline: daemon active + cluster mode"
sudo systemctl is-active --quiet "$DAEMON_SVC" || fail "daemon not running — run \`make deploy\` first"
sudo systemctl start "$NATS_SVC"
wait_field mode cluster 30 || fail "never reached mode=cluster (got: $(field mode))"
[ "$(field nats)" = connected ] || fail "nats not connected (got: $(field nats))"
ok "mode=cluster nats=connected"
# 1. kill NATS → standalone, daemon survives
info "1a/1c: kill NATS"
retry_before=$(field nats_retry_count)
sudo systemctl stop "$NATS_SVC"
wait_field mode standalone 30 || fail "never went standalone (got: $(field mode))"
wait_field nats disconnected 5 || fail "nats not disconnected (got: $(field nats))"
sudo systemctl is-active --quiet "$DAEMON_SVC" || fail "daemon exited — should stay up"
ok "standalone, nats=disconnected, daemon alive"
# 2. /local/instances reachable during partition
info "1b: /local/instances during partition"
"${CURL[@]}" "${DAEMON_URL}/local/instances" | jq -e . >/dev/null \
|| fail "/local/instances did not return JSON"
ok "/local/instances served while NATS down"
# 3. restart NATS → cluster mode + retry count advances + KV push observed
# nats_retry_count counts disconnect→reconnect *cycles* (bumped from
# onNATSReconnect, daemon.go:321). It stays flat during disconnect and
# advances by 1 here when the reconnect callback fires.
info "1c reconnect: restart NATS"
before_sync=$(field last_kv_sync_at)
sudo systemctl start "$NATS_SVC"
wait_field mode cluster 60 || fail "did not return to cluster mode"
wait_field nats connected 10 || fail "nats not reconnected"
sleep 5
retry_after=$(field nats_retry_count)
[ "$retry_after" -gt "$retry_before" ] \
|| fail "nats_retry_count did not advance on reconnect ($retry_before → $retry_after)"
after_sync=$(field last_kv_sync_at)
[ -n "$after_sync" ] && [ "$after_sync" != "$before_sync" ] \
|| fail "last_kv_sync_at did not advance ($before_sync → $after_sync)"
ok "cluster restored, retry $retry_before → $retry_after, KV resync at $after_sync"
# 4. SPINIFEX_REQUIRE_NATS=1 → daemon exits within ~30s when NATS down.
# Block spinifex-nats via a runtime drop-in with ConditionPathExists=!. This
# defeats the daemon unit's Wants=spinifex-nats.service auto-start, which
# would otherwise pull NATS back up on `systemctl restart spinifex-daemon`
# and let the daemon connect immediately. mask refuses (real unit lives in
# /etc) and --runtime mask is shadowed by /etc — the condition drop-in is
# the only mechanism that works against an /etc-installed unit.
info "1d-strict: SPINIFEX_REQUIRE_NATS=1 exits when NATS down"
sudo mkdir -p "$(dirname "$NATS_BLOCK_DROPIN")"
sudo tee "$NATS_BLOCK_DROPIN" >/dev/null <<EOF
[Unit]
ConditionPathExists=!$NATS_BLOCK
EOF
sudo touch "$NATS_BLOCK"
sudo systemctl daemon-reload
sudo systemctl stop "$NATS_SVC"
sudo mkdir -p "$(dirname "$OVERRIDE")"
sudo tee "$OVERRIDE" >/dev/null <<EOF
[Service]
Environment=SPINIFEX_REQUIRE_NATS=1
Restart=no
EOF
sudo systemctl daemon-reload
sudo systemctl restart "$DAEMON_SVC" || true
start=$(date +%s)
while sudo systemctl is-active --quiet "$DAEMON_SVC"; do
[ $(( $(date +%s) - start )) -gt 45 ] && fail "did not exit within 45s"
sleep 1
done
ok "exited after $(( $(date +%s) - start ))s"
echo
echo "DDIL Phase 1 systemd integration: ALL PASS"