signal-bot/scripts/test_watchdog.sh
Krzysztof kuhy Rudnicki d5c63b423b feat: refuse to look healthy while deaf, and heal the container by itself
Today the bot was up, healthy and answering nothing for three hours. A host
firewall change (fixed in testsAndMisc 334c0cd9) cut every Docker container
off the internet; signal-cli lost Signal and reconnected in a loop from
15:32:50 onward. Nothing noticed:

  - the container's own healthcheck answered 204
  - /v1/groups answered 200 out of local state, so discovery saw no change
  - the bot's WebSocket to the container stayed OPEN, so the transport's
    reconnect logic never fired
  - /healthz answered {"status": "ok"} the entire time

The only symptom was silence, which is also what a quiet evening looks like.

signal_bot/liveness.py asks the one question that cannot be answered from a
cache -- GET /v1/devices, which needs a live round trip -- but only after
SILENCE_SECONDS (120) with no frame of any kind, so a busy group costs
nothing and an idle one costs one request per window. Three consecutive
failures and /healthz answers 503 with the reason. Verified end to end in
scripts/e2e.sh against the real process: 503 after 84s with the fake's
connection cut, back to 200 on its own 30s after restoring it.

scripts/signal_api_watchdog.sh + a two-minute user timer do the recovery the
bot cannot do for itself, since a wedged bot cannot restart the container it
depends on. Two tiers, so the steady state costs no Signal round trip at
all: the bot's own /healthz first, the authenticated probe only when that is
not 200. It stops after 3 restarts in 6 hours and escalates instead -- a
deregistered account or a blocked network is not fixable by restarting, and
a restart loop would hide it. Its own bash suite stubs curl and docker and
drives every branch including the give-up cap.

signal_bot/trust.py answers the other half: TRUSTED_INVITERS decides who may
put this bot in a group. The gate sits in GroupDirectory._permitted, which
both _join_pending and _adopt already route through -- gating only invites
would gate the door nobody uses, since prime_profile.sh exists precisely so
that an add becomes a direct join. Evidence is either the group-update
envelope, which is the only frame naming an inviter, or a trusted identity
being a full member in the listing. A refusal is logged once, with that
group's members, so the operator can see which string to add.

verify_allowlist moved to membership.py to keep discovery.py under the
250-line gate; it was already the module that owns MembershipError.

Verified live, by stopping the container by hand and touching nothing else:

  18:54:35  docker stop signal-api
  18:56:36  liveness: failure 1 of 3 after 123s of silence
  18:57:36  failure 3 of 3 -> /healthz answers 503 from 18:57:45
  18:58:18  watchdog: probe failed (1/2)
  19:00:28  watchdog: probe failed (2/2) -> docker restart signal-api
  19:00:36  liveness: receive path recovered after 8 failed probe(s)
  19:01:13  watchdog: signal-api is back

Six minutes, unattended, against three hours of silence this afternoon. All
four real groups are still adopted with TRUSTED_INVITERS=+48123456789 set,
and no group was refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
2026-08-29 19:01:55 +02:00

230 lines
5.8 KiB
Bash
Executable File

#!/bin/bash
# ============================================================================
# Tests for scripts/signal_api_watchdog.sh.
#
# Hermetic: curl and docker are both stubbed on PATH, so nothing here talks to
# a network or a container. The stub docker can "fix" the stub curl's answers,
# which is how the restart-then-recover path is exercised without a restart.
# ============================================================================
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
readonly REPO_ROOT
readonly WATCHDOG="${REPO_ROOT}/scripts/signal_api_watchdog.sh"
WORK=""
FAILURES=0
cleanup() {
if [[ -n "$WORK" && -d "$WORK" ]]; then
rm -rf "$WORK"
fi
}
trap cleanup EXIT
fail() {
echo " FAIL: $1" >&2
FAILURES=$((FAILURES + 1))
}
ok() {
echo " ok: $1"
}
# Rebuild a pristine sandbox: stub binaries, stub answers, empty state.
setup() {
rm -rf "$WORK"
mkdir -p "$WORK/bin" "$WORK/state"
printf '200' >"$WORK/bot_code"
printf '200' >"$WORK/api_code"
printf '0' >"$WORK/fix_on_restart"
cat >"$WORK/bin/curl" <<EOF
#!/bin/bash
url="\${*: -1}"
case "\$url" in
*/healthz) cat "$WORK/bot_code" ;;
*) cat "$WORK/api_code" ;;
esac
EOF
cat >"$WORK/bin/docker" <<EOF
#!/bin/bash
echo "\$@" >> "$WORK/docker.log"
if [[ "\$(cat "$WORK/fix_on_restart")" == "1" ]]; then
printf '200' > "$WORK/bot_code"
printf '200' > "$WORK/api_code"
fi
EOF
chmod +x "$WORK/bin/curl" "$WORK/bin/docker"
}
run_watchdog() {
PATH="$WORK/bin:$PATH" \
PHONE_NUMBER="+15550000000" \
STATE_DIRECTORY="$WORK/state" \
SETTLE_SECONDS=0 \
FAILURES_BEFORE_RESTART=2 \
MAX_RESTARTS=2 \
RESTART_WINDOW=3600 \
"$WATCHDOG" 2>&1
}
expect_contains() {
if [[ "$1" == *"$2"* ]]; then
ok "$3"
else
fail "$3 -- got: $1"
fi
}
expect_missing() {
if [[ "$1" != *"$2"* ]]; then
ok "$3"
else
fail "$3 -- unexpectedly found $2"
fi
}
expect_file() {
local actual
actual="$(cat "$1" 2>/dev/null || echo MISSING)"
if [[ "$actual" == "$2" ]]; then
ok "$3"
else
fail "$3 -- $1 is '$actual', expected '$2'"
fi
}
test_healthy_is_free() {
echo "== a healthy bot needs no restart and no authenticated probe"
setup
run_watchdog >/dev/null
expect_file "$WORK/state/consecutive_failures" "0" "failure counter stays at zero"
if [[ -f "$WORK/docker.log" ]]; then
fail "docker was touched while everything was healthy"
else
ok "docker was never called"
fi
}
test_one_failure_waits() {
echo "== one failed probe is not enough to restart anything"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
local out
out="$(run_watchdog)"
expect_contains "$out" "probe failed (1/2)" "the failure is counted and logged"
expect_file "$WORK/state/consecutive_failures" "1" "counter persisted for the next firing"
if [[ -f "$WORK/docker.log" ]]; then
fail "docker was restarted after a single failure"
else
ok "docker was not restarted"
fi
}
test_confirmed_failure_restarts_and_recovers() {
echo "== two failures restart the container, and recovery is announced"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
printf '1' >"$WORK/fix_on_restart"
run_watchdog >/dev/null
local out
out="$(run_watchdog)"
expect_contains "$(cat "$WORK/docker.log")" "restart signal-api" "the container was restarted"
expect_contains "$out" "is back" "the recovery is announced"
expect_file "$WORK/state/consecutive_failures" "0" "the counter was cleared"
}
test_restart_that_does_not_help() {
echo "== a restart that does not help says so instead of claiming success"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
run_watchdog >/dev/null
local out
out="$(run_watchdog)"
expect_contains "$out" "still deaf" "the failed recovery is reported"
}
test_gives_up_instead_of_looping() {
echo "== it gives up loudly rather than restarting forever"
setup
printf '400' >"$WORK/bot_code"
printf '400' >"$WORK/api_code"
local out="" attempt=0
while ((attempt < 6)); do
out="$(run_watchdog)"
attempt=$((attempt + 1))
done
local restarts
restarts="$(grep -c "restart signal-api" "$WORK/docker.log")"
if [[ "$restarts" == "2" ]]; then
ok "restarts stopped at the cap (2)"
else
fail "expected 2 restarts, got $restarts"
fi
expect_contains "$out" "still deaf" "the give-up message explains what to check"
expect_contains "$out" "out.log" "the give-up message names the log to read"
}
test_requires_a_number() {
echo "== it refuses to run without PHONE_NUMBER"
setup
local out status=0
out="$(PATH="$WORK/bin:$PATH" PHONE_NUMBER="" STATE_DIRECTORY="$WORK/state" \
"$WATCHDOG" 2>&1)" || status=$?
if [[ "$status" == "1" ]]; then
ok "exited non-zero"
else
fail "expected exit 1, got $status"
fi
expect_contains "$out" "PHONE_NUMBER is unset" "it says which value is missing"
}
test_bot_down_but_account_alive() {
echo "== the bot being down is not the container's fault"
setup
printf '000' >"$WORK/bot_code"
local out
out="$(run_watchdog)"
expect_missing "$out" "probe failed" "the authenticated probe carried the check"
if [[ -f "$WORK/docker.log" ]]; then
fail "docker was restarted although the account was reachable"
else
ok "docker was not restarted"
fi
}
main() {
WORK="$(mktemp -d)"
[[ -d "$WORK" ]] || {
echo "Error: failed to create a temporary directory" >&2
exit 1
}
echo "============================================================"
echo "signal_api_watchdog.sh"
echo "============================================================"
test_healthy_is_free
test_bot_down_but_account_alive
test_one_failure_waits
test_confirmed_failure_restarts_and_recovers
test_restart_that_does_not_help
test_gives_up_instead_of_looping
test_requires_a_number
echo "============================================================"
if ((FAILURES > 0)); then
echo "FAILED: $FAILURES check(s)"
exit 1
fi
echo "all checks passed"
}
main "$@"