#!/usr/bin/env bash set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env.load" LABEL=${1:-"resilience-$(date -u +%Y%m%dT%H%M%SZ)"} if [[ ! -f "$ENV_FILE" ]]; then echo "Missing $ENV_FILE. Run scripts/ensure-local-load-env.sh first." >&2 exit 1 fi set -a # shellcheck source=/dev/null . "$ENV_FILE" set +a for name in LOAD_PROJECT LOAD_HOST LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \ LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \ LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS \ LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS \ HTTP_PORT POSTGRES_DB METRICS_TOKEN; do if [[ -z "${!name:-}" ]]; then echo "$name is missing from .env.load" >&2 exit 1 fi done if [[ "$LOAD_PROJECT" == "who_need_help" ]]; then echo "The resilience profile must not use the staging Compose project." >&2 exit 1 fi if [[ ! "$LABEL" =~ ^[A-Za-z0-9._-]+$ ]]; then echo "Run label may contain only letters, numbers, dot, underscore, and dash." >&2 exit 1 fi for name in LOAD_WEB_REPLICAS LOAD_WORKER_REPLICAS \ LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS \ LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS TRAEFIK_RETRY_ATTEMPTS; do if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then echo "$name must be a positive integer." >&2 exit 1 fi done if ! awk -v value="$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS" \ 'BEGIN {exit !(value ~ /^[0-9]+([.][0-9]+)?$/ && value > 0)}'; then echo "LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS must be greater than zero." >&2 exit 1 fi compose=( docker compose --env-file "$ENV_FILE" -p "$LOAD_PROJECT" -f compose.yaml -f compose.load.yaml ) output_dir="$ROOT/output/resilience/$LABEL" mkdir -p "$output_dir" chmod 700 "$ROOT/output" "$ROOT/output/resilience" "$output_dir" run_started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) probe_marker="$output_dir/.probe-running" probe_log="$output_dir/readiness.jsonl" touch "$probe_marker" assert_scope() { local container_id=$1 local expected_service=$2 local observed_project observed_service observed_project=$( docker inspect --format '{{index .Config.Labels "com.docker.compose.project"}}' \ "$container_id" ) observed_service=$( docker inspect --format '{{index .Config.Labels "com.docker.compose.service"}}' \ "$container_id" ) if [[ "$observed_project" != "$LOAD_PROJECT" || "$observed_service" != "$expected_service" ]]; then echo "Container scope mismatch for $container_id." >&2 exit 1 fi } service_ids() { "${compose[@]}" ps -q "$1" } wait_for_service() { local service=$1 local expected=$2 local require_health=$3 local deadline=$((SECONDS + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do mapfile -t ids < <(service_ids "$service") if [[ "${#ids[@]}" -eq "$expected" ]]; then local ready=true local id state health for id in "${ids[@]}"; do state=$(docker inspect --format '{{.State.Status}}' "$id") health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$id") if [[ "$state" != "running" || ("$require_health" == "true" && "$health" != "healthy") ]]; then ready=false break fi done if [[ "$ready" == "true" ]]; then return 0 fi fi sleep 1 done echo "Timed out waiting for $expected ready $service replicas." >&2 return 1 } wait_for_restart() { local container_id=$1 local previous_restarts=$2 local require_health=$3 local deadline=$((SECONDS + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do local state restarts health state=$(docker inspect --format '{{.State.Status}}' "$container_id") restarts=$(docker inspect --format '{{.RestartCount}}' "$container_id") health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container_id") if [[ "$state" == "running" && "$restarts" -gt "$previous_restarts" && ("$require_health" == "false" || "$health" == "healthy") ]]; then return 0 fi sleep 1 done echo "Timed out waiting for container $container_id to restart." >&2 return 1 } wait_for_cluster() { local expected_peers=$((LOAD_WEB_REPLICAS + LOAD_WORKER_REPLICAS - 1)) local deadline=$((SECONDS + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do local target peer_count target=$(service_ids web | head -n 1) assert_scope "$target" web peer_count=$( docker exec "$target" /app/bin/who_need_help rpc \ 'IO.puts(length(Node.list()))' 2>/dev/null | tail -n 1 ) if [[ "$peer_count" == "$expected_peers" ]]; then docker exec "$target" /app/bin/who_need_help rpc \ 'IO.inspect(%{node: node(), peers: Node.list(), peer_count: length(Node.list())})' return 0 fi sleep 1 done echo "Timed out waiting for all local BEAM replicas to join the cluster." >&2 return 1 } sample_readiness() { while [[ -e "$probe_marker" ]]; do observed_at=$(date -u +%Y-%m-%dT%H:%M:%S.%3NZ) body_file="$output_dir/readiness-body.$$" error_file="$output_dir/readiness-error.$$" metrics_headers_file="$output_dir/metrics-headers.$$" set +e status=$( curl --silent --show-error \ --max-time "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" \ --header "Host: $LOAD_HOST" \ --output "$body_file" \ --write-out '%{http_code}' \ "http://localhost:$HTTP_PORT/healthz/ready" 2>"$error_file" ) curl_status=$? set -e if [[ "$curl_status" -ne 0 ]]; then status=000 fi if [[ -f "$body_file" ]]; then body=$(tr -d '\n' <"$body_file") else body= fi set +e metrics_status=$( curl --silent --show-error \ --max-time "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" \ --header "Host: $LOAD_HOST" \ --header "Authorization: Bearer $METRICS_TOKEN" \ --dump-header "$metrics_headers_file" \ --output /dev/null \ --write-out '%{http_code}' \ "http://localhost:$HTTP_PORT/metrics" 2>>"$error_file" ) metrics_curl_status=$? set -e if [[ "$metrics_curl_status" -ne 0 ]]; then metrics_status=000 fi node=$( awk ' BEGIN {IGNORECASE = 1} /^x-wnh-node:/ { sub(/^[^:]+:[[:space:]]*/, "") sub(/\r$/, "") print exit } ' "$metrics_headers_file" 2>/dev/null || true ) error=$(tr -d '\n' <"$error_file") unlink "$body_file" 2>/dev/null || true unlink "$error_file" 2>/dev/null || true unlink "$metrics_headers_file" 2>/dev/null || true jq -cn \ --arg observed_at "$observed_at" \ --arg status "$status" \ --arg metrics_status "$metrics_status" \ --arg node "$node" \ --arg body "$body" \ --arg error "$error" \ '{ observed_at: $observed_at, status: $status, metrics_status: $metrics_status, node: $node, body: $body, error: $error }' \ >>"$probe_log" sleep "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS" done } stop_probe() { unlink "$probe_marker" 2>/dev/null || true if [[ -n "${probe_pid:-}" ]]; then wait "$probe_pid" 2>/dev/null || true fi } delete_retry_job() { local destination=$1 local target target=$(service_ids worker | head -n 1) assert_scope "$target" worker docker exec "$target" /app/bin/who_need_help rpc \ "import Ecto.Query run_id = \"$LABEL\" {count, _} = WhoNeedHelp.Repo.delete_all( from job in Oban.Job, where: job.worker == \"WhoNeedHelp.Workers.LocalRetryProbe\" and fragment(\"?->>'run_id'\", job.args) == ^run_id ) IO.puts(count)" >"$destination" } restore_topology() { "${compose[@]}" up -d --no-deps \ --scale "web=$LOAD_WEB_REPLICAS" \ --scale "worker=$LOAD_WORKER_REPLICAS" \ web worker >/dev/null 2>&1 || true } cleanup() { local status=$? local cleanup_status=0 trap - EXIT HUP INT TERM stop_probe set +e restore_topology wait_for_service worker "$LOAD_WORKER_REPLICAS" false if [[ "${retry_job_inserted:-false}" == "true" ]]; then delete_retry_job "$output_dir/retry-job-cleanup-on-exit.txt" cleanup_status=$? fi set -e if [[ "$cleanup_status" -ne 0 ]]; then echo "Resilience probe cleanup failed." >&2 status=1 fi exit "$status" } trap cleanup EXIT HUP INT TERM wait_for_service web "$LOAD_WEB_REPLICAS" true wait_for_service worker "$LOAD_WORKER_REPLICAS" false { printf 'observed_at=%s\n' "$run_started_at" printf 'load_project=%s\n' "$LOAD_PROJECT" printf 'web_replicas=%s\n' "$LOAD_WEB_REPLICAS" printf 'worker_replicas=%s\n' "$LOAD_WORKER_REPLICAS" printf 'recovery_timeout_seconds=%s\n' "$LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS" printf 'probe_interval_seconds=%s\n' "$LOAD_RESILIENCE_PROBE_INTERVAL_SECONDS" printf 'request_timeout_seconds=%s\n' "$LOAD_RESILIENCE_REQUEST_TIMEOUT_SECONDS" printf 'traefik_retry_attempts=%s\n' "$TRAEFIK_RETRY_ATTEMPTS" docker compose version docker info --format 'docker_server={{.ServerVersion}}' } >"$output_dir/environment.txt" sample_readiness & probe_pid=$! web_target=$(service_ids web | head -n 1) assert_scope "$web_target" web web_restarts=$(docker inspect --format '{{.RestartCount}}' "$web_target") docker exec "$web_target" /app/bin/who_need_help rpc ':erlang.halt(1)' \ >"$output_dir/web-kill.txt" 2>&1 || true wait_for_restart "$web_target" "$web_restarts" true docker inspect "$web_target" | jq '.[0] | { id: .Id, project: .Config.Labels["com.docker.compose.project"], service: .Config.Labels["com.docker.compose.service"], restart_count: .RestartCount, state: .State.Status, health: .State.Health.Status }' >"$output_dir/web-restart.json" worker_target=$(service_ids worker | head -n 1) assert_scope "$worker_target" worker worker_restarts=$(docker inspect --format '{{.RestartCount}}' "$worker_target") docker exec "$worker_target" /app/bin/who_need_help rpc ':erlang.halt(1)' \ >"$output_dir/worker-kill.txt" 2>&1 || true wait_for_restart "$worker_target" "$worker_restarts" false docker inspect "$worker_target" | jq '.[0] | { id: .Id, project: .Config.Labels["com.docker.compose.project"], service: .Config.Labels["com.docker.compose.service"], restart_count: .RestartCount, state: .State.Status }' >"$output_dir/worker-restart.json" mapfile -t original_web_ids < <(service_ids web) for container_id in "${original_web_ids[@]}"; do assert_scope "$container_id" web { docker stop --timeout 30 "$container_id" docker rm "$container_id" "${compose[@]}" up -d --no-deps --scale "web=$LOAD_WEB_REPLICAS" web } >>"$output_dir/web-replacements.txt" wait_for_service web "$LOAD_WEB_REPLICAS" true done mapfile -t original_worker_ids < <(service_ids worker) for container_id in "${original_worker_ids[@]}"; do assert_scope "$container_id" worker { docker stop --timeout 30 "$container_id" docker rm "$container_id" "${compose[@]}" up -d --no-deps --scale "worker=$LOAD_WORKER_REPLICAS" worker } >>"$output_dir/worker-replacements.txt" wait_for_service worker "$LOAD_WORKER_REPLICAS" false done wait_for_cluster >"$output_dir/cluster-after-replacements.txt" COMPOSE_PROJECT_NAME=$LOAD_PROJECT "$ROOT/scripts/verify-realtime-cluster.sh" compose \ >"$output_dir/pubsub-after-replacements.txt" worker_target=$(service_ids worker | head -n 1) assert_scope "$worker_target" worker docker exec "$worker_target" /app/bin/who_need_help rpc \ "run_id = \"$LABEL\" {:ok, job} = %{ \"confirmation\" => \"isolated-local-resilience-probe\", \"run_id\" => run_id } |> WhoNeedHelp.Workers.LocalRetryProbe.new() |> Oban.insert() IO.puts(job.id)" >"$output_dir/retry-job-insert.txt" retry_job_inserted=true retry_deadline=$((SECONDS + LOAD_RESILIENCE_RECOVERY_TIMEOUT_SECONDS)) while ((SECONDS < retry_deadline)); do # The run label is restricted above and passed as a psql value. # shellcheck disable=SC2016 "${compose[@]}" exec -T db sh -c \ 'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --set run_id="$1" --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \ sh "$LABEL" >"$output_dir/retry-job.json" <<'SQL' SELECT json_build_object( 'id', id, 'state', state, 'attempt', attempt, 'max_attempts', max_attempts, 'error_count', cardinality(errors), 'worker', worker ) FROM oban_jobs WHERE worker = 'WhoNeedHelp.Workers.LocalRetryProbe' AND args->>'run_id' = :'run_id' ORDER BY id DESC LIMIT 1; SQL if jq -e ' .state == "completed" and .attempt == 2 and .max_attempts == 2 and .error_count == 1 and .worker == "WhoNeedHelp.Workers.LocalRetryProbe" ' "$output_dir/retry-job.json" >/dev/null 2>&1; then break fi sleep 1 done if ! jq -e ' .state == "completed" and .attempt == 2 and .max_attempts == 2 and .error_count == 1 and .worker == "WhoNeedHelp.Workers.LocalRetryProbe" ' "$output_dir/retry-job.json" >/dev/null; then echo "The isolated Oban retry probe did not complete its second attempt." >&2 exit 1 fi delete_retry_job "$output_dir/retry-job-cleanup.txt" if [[ "$(tail -n 1 "$output_dir/retry-job-cleanup.txt")" != "1" ]]; then echo "The exact resilience probe job was not removed." >&2 exit 1 fi retry_job_inserted=false stop_probe probe_pid= jq -s '{ samples: length, failures: (map(select(.status != "200" or .metrics_status != "200")) | length), nodes: (map(.node) | map(select(. != null and . != "")) | unique) }' "$probe_log" >"$output_dir/readiness-summary.json" if ! jq -e '.samples > 0 and .failures == 0 and (.nodes | length) >= 2' \ "$output_dir/readiness-summary.json" >/dev/null; then echo "Readiness was unavailable or did not reach multiple web replicas." >&2 exit 1 fi "${compose[@]}" ps -a >"$output_dir/compose-after.txt" "${compose[@]}" logs --since "$run_started_at" proxy web worker \ >"$output_dir/application.log" 2>&1 trap - EXIT HUP INT TERM printf 'Resilience evidence: %s\n' "$output_dir"