Files
Ben Duncan 225bf65a8e feat: EBS export tunables, teardown reapers, fan-out and OVN fixes (#800)
Merges `feat/ebs-export-tunables` (83 commits) into dev. The branch could not be rebase-merged because it carries five merges from dev, so it lands as a squash.

## What is in it

- **EBS export tunables** and the nbdkit/viperblock export path they configure.
- **Teardown reapers and backoff** for S3 buckets, load balancers, gateways, spot requests, certificates, endpoints, elastic IPs and network interfaces, so a dependent that keeps failing is backed off rather than retried hot.
- **Retry and tolerance paths**: ECS IAM service init, ECR signing keys deleted between the list and the read, an unreadable signing key at awsgw startup, and an ECS instance-profile policy attach converging on a stale read.
- **Key pair validation reads the material by name** instead of listing the account, closing the `InvalidKeyPair.NotFound` race that a single-replica LIST could produce.
- **Concurrency and fan-out**: concurrent metadata document fetches, bounded metadata reads for describes, and the node-discovery timeout removed from every fan-out call.
- **OVN correctness against the configured NB cluster**: the flows barrier, nexthop MAC binding, and stopping a compute node from running the package's standalone database.
- **nats-js-watchdog can re-arm itself.** The escalation check ran above the probe loop while the only line that cleared the counter sat below it, so a latched watchdog could never observe a recovery. It now probes first and decides second, and says so once per latch rather than once per tick.
- **CI**: 19 commits of nightly and build-pipeline work.

## Verification

`make preflight` green locally. Build and Test, Integration Tests, Race Detection, and Lint and Security all green on `c2da0385`.

## Known red, not introduced here

Two nightly cells fail on this branch and are pre-existing:

- **cell-10** — a partial OVN IPsec enable leaves `NB_Global ipsec=true` asserted cluster-wide while two of three chassis failed their local reconcile, black-holing cross-chassis guest traffic for roughly 5.5 minutes while every health signal stays green.
- **cell-19** — a total layer-3 egress blackhole below a correctly and promptly programmed NAT/EIP datapath, aggravated by a natuplink probe whose retry budget outlives the test's own wait, so it can never emit its FAIL verdict.

Both are filed separately and should be weighed before a release is cut.
2026-08-17 17:42:27 -05:00

191 lines
7.8 KiB
Bash
Executable File

#!/bin/sh
# JetStream liveness watchdog for spinifex-nats.
#
# NATS can stop accepting JetStream writes at runtime while the process stays
# alive and keeps serving reads. That is not a process failure, so
# Restart=on-failure never fires and KV-backed services (awsgw IAM bucket init,
# etc.) stall until NATS is restarted by hand.
#
# Two distinct latches produce that symptom, and they need different probes:
#
# 1. Server-wide. handleOutOfSpace calls ShutdownJetStream and sets an
# internal disabled flag that only a restart clears. healthz reports it.
# 2. Per message block. filestore records a write error on the block and
# returns it on every later write; nothing clears it short of a restart.
# healthz does NOT report it, because the server-wide state is untouched —
# only an actual write observes it.
#
# So a health check alone misses case 2 entirely. Both probes run here, and a
# sustained failure of either restarts the service. The multi-sample window
# avoids restarting during transient startup/quorum 503s.
#
# Three more guards sit around that decision, because "restart when the
# probes fail" is not safe on its own:
#
# - Age gate: spinifex-nats.service is Type=simple, so it reports active the
# instant the process forks, long before JetStream finishes replaying its
# state. Acting before a recovery budget has elapsed restarts a server
# that is still coming up, not one that has failed.
# - Cooldown: a restart that did not fix things must not be retried by the
# very next run, seconds later.
# - Escalation: a restart that has not fixed things after several attempts
# is not going to fix it on the next one either, and an unrunnable spx
# must never be read as proof of a JetStream failure at all.
set -eu
HEALTH_URL="http://127.0.0.1:8222/healthz?js-enabled-only=true"
SERVICE="spinifex-nats.service"
SPX="${SPX_BIN:-/usr/local/bin/spx}"
SAMPLES="${SAMPLES:-3}" # consecutive failing samples required before acting
SAMPLE_GAP="${SAMPLE_GAP:-2}" # seconds between samples
PROBE_TIMEOUT="${PROBE_TIMEOUT:-5}" # seconds for one canary round-trip
# Recovery budget: on casuarina NATS needed 3m49s between "Started" and
# JetStream actually accepting writes, and the bead recorded ~6min elsewhere.
# 600s clears both with margin.
NATS_MIN_AGE="${NATS_MIN_AGE:-600}"
# Minimum time between restarts this watchdog performs, independent of the
# age gate: closes the case where a restart did not fix things and the very
# next run pulls the trigger again before the new process could recover.
RESTART_COOLDOWN="${RESTART_COOLDOWN:-600}"
# A restart that has not fixed things after this many attempts is not going
# to fix it on the next one either, and looping costs more than stopping.
RESTART_ESCALATE_AT="${RESTART_ESCALATE_AT:-3}"
STATE_DIR="${STATE_DIR:-/run/spinifex}"
COOLDOWN_STAMP="$STATE_DIR/nats-js-watchdog.last-restart"
RESTART_COUNT_FILE="$STATE_DIR/nats-js-watchdog.restart-count"
# Marks that the escalation message has already been printed for the current
# latch, so a node that stays broken says so once instead of once per tick.
ESCALATED_STAMP="$STATE_DIR/nats-js-watchdog.escalated"
# Monotonic clock source, so a wall-clock step during boot (NTP correcting an
# unset RTC) cannot manufacture a false age or cooldown. Overridable so tests
# can stub a clock without faking /proc.
UPTIME_FILE="${UPTIME_FILE:-/proc/uptime}"
uptime_seconds() {
awk '{printf "%d", $1}' "$UPTIME_FILE"
}
# nats_age_seconds prints how long $SERVICE has been running. It reads
# ExecMainStartTimestampMonotonic rather than is-active: that property goes
# true the instant Type=simple forks, long before JetStream has replayed its
# state, so it cannot express the recovery window this gate exists to
# protect. A unit systemd has never started prints 0, which also covers a
# stopped/activating unit the way the old is-active guard did.
nats_age_seconds() {
start_usec=$(systemctl show --property=ExecMainStartTimestampMonotonic --value "$SERVICE" 2>/dev/null) || start_usec=""
case "$start_usec" in
''|*[!0-9]*) echo 0; return ;;
esac
if [ "$start_usec" -eq 0 ]; then
echo 0
return
fi
echo $(( $(uptime_seconds) - start_usec / 1000000 ))
}
age=$(nats_age_seconds)
if [ "$age" -lt "$NATS_MIN_AGE" ]; then
exit 0
fi
if [ -f "$COOLDOWN_STAMP" ]; then
last=$(cat "$COOLDOWN_STAMP" 2>/dev/null) || last=""
case "$last" in ''|*[!0-9]*) last=0 ;; esac
if [ $(( $(uptime_seconds) - last )) -lt "$RESTART_COOLDOWN" ]; then
exit 0
fi
fi
restart_count=$(cat "$RESTART_COUNT_FILE" 2>/dev/null) || restart_count=""
case "$restart_count" in ''|*[!0-9]*) restart_count=0 ;; esac
# The escalation count is read here but acted on only after the probes, below.
# Checking it up front would return before the one line that clears it, so a
# latched watchdog could never observe recovery and never re-arm itself.
# curl is the probe transport; if it is unavailable, do nothing rather than
# false-restart a healthy server.
if ! command -v curl >/dev/null 2>&1; then
echo "nats-js-watchdog: curl not found, skipping JS health probe" >&2
exit 0
fi
js_healthy() {
curl -fsS -o /dev/null --max-time 5 "$HEALTH_URL" 2>/dev/null
}
# Round-trips a canary key through the cluster-state bucket. Exit 0 means the
# write succeeded and exit 1 means JetStream refused it; anything else
# (spx's own exit 2, a missing binary, or one that cannot run at all) means
# the probe observed nothing about JetStream and must never be read as
# evidence of a failure.
js_writable() {
if [ ! -x "$SPX" ]; then
return 2
fi
"$SPX" admin node js-probe --timeout "${PROBE_TIMEOUT}s" >/dev/null 2>&1
rc=$?
case "$rc" in
0) return 0 ;;
1) return 1 ;;
*) return 2 ;;
esac
}
failure=""
i=1
while [ "$i" -le "$SAMPLES" ]; do
if js_healthy; then
if js_writable; then
wrc=0
else
wrc=$?
fi
if [ "$wrc" -eq 0 ]; then
# Recovery: drop the history so a later failure gets the full
# restart budget again, and re-arm the escalation message.
rm -f "$RESTART_COUNT_FILE" "$ESCALATED_STAMP"
exit 0
fi
if [ "$wrc" -eq 2 ]; then
echo "nats-js-watchdog: js-probe inconclusive (config, connect, bucket-open, or spx itself failed); not evidence of a JetStream failure, taking no action" >&2
exit 0
fi
failure="JetStream accepts no writes (canary round-trip failed) while $HEALTH_URL reports healthy"
else
failure="JetStream unhealthy at $HEALTH_URL"
fi
if [ "$i" -lt "$SAMPLES" ]; then
sleep "$SAMPLE_GAP"
fi
i=$((i + 1))
done
# The failure is real and current, so the restart history now means what it
# says. Above the threshold another restart will not help, so stop acting —
# but keep probing on later runs, because that is what notices a recovery.
if [ "$restart_count" -ge "$RESTART_ESCALATE_AT" ]; then
if [ ! -f "$ESCALATED_STAMP" ]; then
mkdir -p "$STATE_DIR"
: > "$ESCALATED_STAMP"
echo "nats-js-watchdog: already restarted $SERVICE $restart_count time(s) without recovery; giving up rather than looping (clear $RESTART_COUNT_FILE to re-arm)" >&2
fi
exit 0
fi
echo "nats-js-watchdog: $failure on $SAMPLES consecutive probes; restarting $SERVICE" >&2
mkdir -p "$STATE_DIR"
uptime_seconds > "$COOLDOWN_STAMP"
restart_count=$((restart_count + 1))
echo "$restart_count" > "$RESTART_COUNT_FILE"
if [ "$restart_count" -ge "$RESTART_ESCALATE_AT" ]; then
echo "nats-js-watchdog: restart $restart_count of $RESTART_ESCALATE_AT before escalation; if $SERVICE is still failing after this, the watchdog will stop acting until $RESTART_COUNT_FILE is cleared" >&2
fi
systemctl try-restart "$SERVICE"