#!/usr/bin/env bash set -euo pipefail ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ENV_FILE="$ROOT/.env.load" LABEL=${1:-"observability-$(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_WEB_REPLICAS LOAD_WORKER_REPLICAS POSTGRES_DB METRICS_TOKEN \ OBSERVABILITY_PROMETHEUS_PORT OBSERVABILITY_ALERTMANAGER_PORT \ OBSERVABILITY_GRAFANA_PORT OBSERVABILITY_SCRAPE_INTERVAL \ OBSERVABILITY_EVALUATION_INTERVAL OBSERVABILITY_TIMEOUT_SECONDS \ OBSERVABILITY_GRAFANA_ADMIN_USER OBSERVABILITY_GRAFANA_ADMIN_PASSWORD; 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 observability drill 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; do if [[ ! "${!name}" =~ ^[1-9][0-9]*$ ]]; then echo "$name must be a positive integer." >&2 exit 1 fi done for name in OBSERVABILITY_PROMETHEUS_PORT OBSERVABILITY_ALERTMANAGER_PORT \ OBSERVABILITY_GRAFANA_PORT; do if [[ ! "${!name}" =~ ^[0-9]+$ ]] || ((10#${!name} > 65535)); then echo "$name must be a TCP port number from 0 through 65535." >&2 exit 1 fi done for name in OBSERVABILITY_SCRAPE_INTERVAL OBSERVABILITY_EVALUATION_INTERVAL; do if [[ ! "${!name}" =~ ^[1-9][0-9]*(ms|s|m|h)$ ]]; then echo "$name must be a positive Prometheus duration using ms, s, m, or h." >&2 exit 1 fi done if [[ ! "$OBSERVABILITY_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then echo "OBSERVABILITY_TIMEOUT_SECONDS must be a positive integer." >&2 exit 1 fi for command in curl docker jq sed; do if ! command -v "$command" >/dev/null 2>&1; then echo "Required command is unavailable: $command" >&2 exit 1 fi done runtime_dir="$ROOT/tmp/observability/$LOAD_PROJECT" output_dir="$ROOT/output/observability/$LABEL" prometheus_runtime="$runtime_dir/prometheus" grafana_runtime="$runtime_dir/grafana" if [[ -e "$output_dir" ]]; then echo "Output already exists: $output_dir" >&2 exit 1 fi mkdir -p "$prometheus_runtime" "$grafana_runtime" "$output_dir" chmod 700 "$ROOT/tmp" "$ROOT/tmp/observability" "$runtime_dir" \ "$ROOT/output" "$ROOT/output/observability" "$output_dir" export OBSERVABILITY_RUNTIME_DIR="$runtime_dir" set_runtime_owner() { local directory=$1 local owner=$2 docker run --rm \ --user 0:0 \ --volume "$directory:/runtime" \ --entrypoint /bin/sh \ python:3.14.6-alpine3.23@sha256:b165067c5afc37fa5608a3c05609cc3d51aafd808a30fbfd822ee594fef55ad4 \ -euc "chown -R $owner /runtime; chmod 700 /runtime" } host_owner="$(id -u):$(id -g)" set_runtime_owner "$prometheus_runtime" "$host_owner" set_runtime_owner "$grafana_runtime" "$host_owner" compose=( docker compose --env-file "$ENV_FILE" -p "$LOAD_PROJECT" -f compose.yaml -f compose.load.yaml -f compose.observability.yaml --profile observability ) service_ids() { "${compose[@]}" ps --all -q "$1" } 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 } wait_for_web() { local container_id=$1 local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do local state health state=$(docker inspect --format '{{.State.Status}}' "$container_id") health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{end}}' \ "$container_id") if [[ "$state" == "running" && "$health" == "healthy" ]]; then return 0 fi sleep 1 done echo "Timed out waiting for web container $container_id to become healthy." >&2 return 1 } database_snapshot() { # Variables are intentionally expanded inside the isolated PostGIS container. # shellcheck disable=SC2016 "${compose[@]}" exec -T db sh -c \ 'psql --no-psqlrc --tuples-only --no-align --set ON_ERROR_STOP=1 \ --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' >"$1" <<'SQL' BEGIN READ ONLY; SELECT 'users' AS table_name, count(*) AS row_count FROM users UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens UNION ALL SELECT 'help_requests', count(*) FROM help_requests UNION ALL SELECT 'messages', count(*) FROM messages UNION ALL SELECT 'categories', count(*) FROM categories UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments UNION ALL SELECT 'activities', count(*) FROM activities UNION ALL SELECT 'reports', count(*) FROM reports UNION ALL SELECT 'social_identities', count(*) FROM social_identities UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions UNION ALL SELECT 'schema_migrations', count(*) FROM schema_migrations ORDER BY table_name; COMMIT; SQL } published_url() { local service=$1 local target_port=$2 local published published=$("${compose[@]}" port "$service" "$target_port" | head -n 1) if [[ ! "$published" =~ ^127[.]0[.]0[.]1:([1-9][0-9]*)$ ]]; then echo "The $service port is not bound to an observed loopback port." >&2 return 1 fi printf 'http://%s' "$published" } wait_for_http() { local url=$1 shift local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do if curl --fail --silent --show-error "$@" "$url" >/dev/null 2>&1; then return 0 fi sleep 1 done echo "Timed out waiting for $url." >&2 return 1 } fetch_receiver_events() { local receiver_id receiver_id=$(service_ids alert-receiver | head -n 1) assert_scope "$receiver_id" alert-receiver docker exec "$receiver_id" \ python -c \ 'import urllib.request; print(urllib.request.urlopen("http://127.0.0.1:8080/events", timeout=2).read().decode())' } wait_for_targets_up() { local expected_web=$1 local expected_worker=$2 local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do if curl --fail --silent --show-error \ "$prometheus_url/api/v1/targets?state=active" \ >"$output_dir/targets-current.json" 2>/dev/null && jq -e \ --argjson expected_web "$expected_web" \ --argjson expected_worker "$expected_worker" \ --arg run_id "$LABEL" ' [ .data.activeTargets[] | select( .labels.job == "who-need-help-web" and .labels.run_id == $run_id ) ] as $web_targets | [ .data.activeTargets[] | select( .labels.job == "who-need-help-worker" and .labels.run_id == $run_id ) ] as $worker_targets | ($web_targets | length) == $expected_web and ($worker_targets | length) == $expected_worker and all($web_targets[]; .health == "up") and all($worker_targets[]; .health == "up") ' "$output_dir/targets-current.json" >/dev/null; then return 0 fi sleep 1 done echo "Timed out waiting for every direct web and worker metrics target." >&2 return 1 } wait_for_firing_alert() { local instance=$1 local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do if curl --fail --silent --show-error "$prometheus_url/api/v1/alerts" \ >"$output_dir/prometheus-alerts-firing.json" 2>/dev/null && jq -e --arg instance "$instance" --arg run_id "$LABEL" ' any( .data.alerts[]; .state == "firing" and .labels.alertname == "WhoNeedHelpWebReplicaUnavailable" and .labels.instance == $instance and .labels.run_id == $run_id ) ' "$output_dir/prometheus-alerts-firing.json" >/dev/null; then return 0 fi sleep 1 done echo "Timed out waiting for the induced Prometheus alert." >&2 return 1 } wait_for_webhook_status() { local instance=$1 local expected_status=$2 local destination=$3 local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do if fetch_receiver_events >"$destination" 2>/dev/null && jq -e \ --arg instance "$instance" \ --arg run_id "$LABEL" \ --arg expected_status "$expected_status" ' any( .[].payload.alerts[]; .status == $expected_status and .labels.alertname == "WhoNeedHelpWebReplicaUnavailable" and .labels.instance == $instance and .labels.run_id == $run_id ) ' "$destination" >/dev/null; then return 0 fi sleep 1 done echo "Timed out waiting for the $expected_status Alertmanager webhook." >&2 return 1 } wait_for_alert_clear() { local instance=$1 local deadline=$((SECONDS + OBSERVABILITY_TIMEOUT_SECONDS)) while ((SECONDS < deadline)); do if curl --fail --silent --show-error "$prometheus_url/api/v1/alerts" \ >"$output_dir/prometheus-alerts-resolved.json" 2>/dev/null && jq -e --arg instance "$instance" --arg run_id "$LABEL" ' all( .data.alerts[]; .labels.alertname != "WhoNeedHelpWebReplicaUnavailable" or .labels.instance != $instance or .labels.run_id != $run_id or .state != "firing" ) ' "$output_dir/prometheus-alerts-resolved.json" >/dev/null; then return 0 fi sleep 1 done echo "Timed out waiting for the induced Prometheus alert to resolve." >&2 return 1 } restore_web() { if [[ -n "${drill_web_id:-}" ]]; then local state state=$(docker inspect --format '{{.State.Status}}' "$drill_web_id" 2>/dev/null || true) if [[ "$state" != "running" ]]; then docker start "$drill_web_id" >/dev/null 2>&1 || true fi wait_for_web "$drill_web_id" >/dev/null 2>&1 || true fi } cleanup() { local status=$? trap - EXIT HUP INT TERM restore_web exit "$status" } trap cleanup EXIT HUP INT TERM mapfile -t web_ids < <(service_ids web) mapfile -t worker_ids < <(service_ids worker) if [[ "${#web_ids[@]}" -ne "$LOAD_WEB_REPLICAS" ]]; then echo "Expected $LOAD_WEB_REPLICAS running load web replicas; observed ${#web_ids[@]}." >&2 exit 1 fi if [[ "${#worker_ids[@]}" -ne "$LOAD_WORKER_REPLICAS" ]]; then echo "Expected $LOAD_WORKER_REPLICAS running load worker replicas; observed ${#worker_ids[@]}." >&2 exit 1 fi internal_network=$( docker network ls \ --filter "label=com.docker.compose.project=$LOAD_PROJECT" \ --filter "label=com.docker.compose.network=internal" \ --format '{{.Name}}' ) if [[ -z "$internal_network" || "$internal_network" == *$'\n'* ]]; then echo "Expected exactly one observed Compose internal network." >&2 exit 1 fi target_lines="$prometheus_runtime/web-targets.jsonl" : >"$target_lines" for web_id in "${web_ids[@]}"; do assert_scope "$web_id" web wait_for_web "$web_id" web_name=$(docker inspect --format '{{.Name}}' "$web_id") web_name=${web_name#/} web_ip=$( docker inspect \ --format "{{with index .NetworkSettings.Networks \"$internal_network\"}}{{.IPAddress}}{{end}}" \ "$web_id" ) if [[ ! "$web_ip" =~ ^[0-9]+([.][0-9]+){3}$ ]]; then echo "No observed IPv4 address for $web_name on $internal_network." >&2 exit 1 fi jq -cn \ --arg target "$web_ip:4000" \ --arg instance "$web_name" \ --arg compose_project "$LOAD_PROJECT" \ --arg run_id "$LABEL" \ '{ targets: [$target], labels: { instance: $instance, compose_project: $compose_project, run_id: $run_id } }' >>"$target_lines" done jq -s '.' "$target_lines" >"$prometheus_runtime/web-targets.json" unlink "$target_lines" target_lines="$prometheus_runtime/worker-targets.jsonl" : >"$target_lines" for worker_id in "${worker_ids[@]}"; do assert_scope "$worker_id" worker wait_for_web "$worker_id" worker_name=$(docker inspect --format '{{.Name}}' "$worker_id") worker_name=${worker_name#/} worker_ip=$( docker inspect \ --format "{{with index .NetworkSettings.Networks \"$internal_network\"}}{{.IPAddress}}{{end}}" \ "$worker_id" ) if [[ ! "$worker_ip" =~ ^[0-9]+([.][0-9]+){3}$ ]]; then echo "No observed IPv4 address for $worker_name on $internal_network." >&2 exit 1 fi jq -cn \ --arg target "$worker_ip:4000" \ --arg instance "$worker_name" \ --arg compose_project "$LOAD_PROJECT" \ --arg run_id "$LABEL" \ '{ targets: [$target], labels: { instance: $instance, compose_project: $compose_project, run_id: $run_id } }' >>"$target_lines" done jq -s '.' "$target_lines" >"$prometheus_runtime/worker-targets.json" unlink "$target_lines" sed \ -e "s/__SCRAPE_INTERVAL__/$OBSERVABILITY_SCRAPE_INTERVAL/g" \ -e "s/__EVALUATION_INTERVAL__/$OBSERVABILITY_EVALUATION_INTERVAL/g" \ "$ROOT/ops/observability/prometheus.template.yml" \ >"$prometheus_runtime/prometheus.yml" printf '%s' "$METRICS_TOKEN" >"$prometheus_runtime/metrics-token" printf '%s' "$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" \ >"$grafana_runtime/admin-password" cp "$prometheus_runtime/web-targets.json" "$output_dir/generated-targets.json" cp "$prometheus_runtime/worker-targets.json" "$output_dir/generated-worker-targets.json" chmod 600 "$prometheus_runtime/prometheus.yml" \ "$prometheus_runtime/web-targets.json" \ "$prometheus_runtime/worker-targets.json" \ "$prometheus_runtime/metrics-token" \ "$grafana_runtime/admin-password" \ "$output_dir/generated-targets.json" \ "$output_dir/generated-worker-targets.json" set_runtime_owner "$prometheus_runtime" "65534:65534" set_runtime_owner "$grafana_runtime" "472:0" docker run --rm \ --volume "$prometheus_runtime:/runtime:ro" \ --volume "$ROOT/ops/observability/rules.yml:/etc/prometheus/rules.yml:ro" \ --entrypoint /bin/promtool \ quay.io/prometheus/prometheus:v3.13.1@sha256:3c42b892cf723fa54d2f262c37a0e1f80aa8c8ddb1da7b9b0df9455a35a7f893 \ check config /runtime/prometheus.yml \ >"$output_dir/promtool-check.txt" docker run --rm \ --volume "$ROOT/ops/observability/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro" \ --entrypoint /bin/amtool \ quay.io/prometheus/alertmanager:v0.33.1@sha256:9e082985f56f4c8c9f724e18f2288c6708f472e56a5286b8863d080434ea065d \ check-config /etc/alertmanager/alertmanager.yml \ >"$output_dir/amtool-check.txt" jq --exit-status 'type == "object" and .uid == "wnh-overview"' \ "$ROOT/ops/observability/grafana/dashboards/who-need-help-overview.json" \ >/dev/null for service in alert-receiver alertmanager prometheus grafana; do while IFS= read -r existing_id; do [[ -n "$existing_id" ]] && assert_scope "$existing_id" "$service" done < <(service_ids "$service") done database_snapshot "$output_dir/database-before.txt" "${compose[@]}" up -d --force-recreate --wait \ alert-receiver alertmanager prometheus grafana \ >"$output_dir/compose-up.txt" for service in alert-receiver alertmanager prometheus grafana; do service_id=$(service_ids "$service" | head -n 1) assert_scope "$service_id" "$service" done prometheus_url=$(published_url prometheus 9090) alertmanager_url=$(published_url alertmanager 9093) grafana_url=$(published_url grafana 3000) wait_for_http "$prometheus_url/-/ready" wait_for_http "$alertmanager_url/-/ready" wait_for_http "$grafana_url/api/health" wait_for_http "$grafana_url/api/datasources/uid/wnh-prometheus/health" \ --user "$OBSERVABILITY_GRAFANA_ADMIN_USER:$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" curl --fail --silent --show-error \ --user "$OBSERVABILITY_GRAFANA_ADMIN_USER:$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" \ "$grafana_url/api/health" >"$output_dir/grafana-health.json" curl --fail --silent --show-error \ --user "$OBSERVABILITY_GRAFANA_ADMIN_USER:$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" \ "$grafana_url/api/datasources/uid/wnh-prometheus/health" \ >"$output_dir/grafana-datasource-health.json" curl --fail --silent --show-error \ --user "$OBSERVABILITY_GRAFANA_ADMIN_USER:$OBSERVABILITY_GRAFANA_ADMIN_PASSWORD" \ "$grafana_url/api/dashboards/uid/wnh-overview" \ >"$output_dir/grafana-dashboard.json" if ! jq -e ' .dashboard.uid == "wnh-overview" and .meta.provisioned == true and (.dashboard.panels | length) == 10 ' "$output_dir/grafana-dashboard.json" >/dev/null; then echo "The provisioned Grafana dashboard did not match the tracked dashboard." >&2 exit 1 fi wait_for_targets_up "$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS" mv "$output_dir/targets-current.json" "$output_dir/targets-before-drill.json" jq -n \ --slurpfile expected_web "$output_dir/generated-targets.json" \ --slurpfile expected_worker "$output_dir/generated-worker-targets.json" \ --slurpfile observed "$output_dir/targets-before-drill.json" ' { expected_web_instances: ($expected_web[0] | map(.labels.instance) | sort), observed_web_instances: ( $observed[0].data.activeTargets | map(select(.labels.job == "who-need-help-web") | .labels.instance) | sort ), expected_worker_instances: ($expected_worker[0] | map(.labels.instance) | sort), observed_worker_instances: ( $observed[0].data.activeTargets | map(select(.labels.job == "who-need-help-worker") | .labels.instance) | sort ) } | . + { exact_instance_match: (.expected_web_instances == .observed_web_instances and .expected_worker_instances == .observed_worker_instances) } ' >"$output_dir/target-summary.json" if ! jq -e '.exact_instance_match' "$output_dir/target-summary.json" >/dev/null; then echo "Prometheus did not preserve every direct web and worker instance target." >&2 exit 1 fi drill_web_id=${web_ids[0]} assert_scope "$drill_web_id" web drill_instance=$(docker inspect --format '{{.Name}}' "$drill_web_id") drill_instance=${drill_instance#/} docker stop --time 30 "$drill_web_id" >"$output_dir/stopped-web.txt" wait_for_firing_alert "$drill_instance" wait_for_webhook_status \ "$drill_instance" firing "$output_dir/alert-webhooks-firing.json" docker start "$drill_web_id" >"$output_dir/started-web.txt" wait_for_web "$drill_web_id" wait_for_targets_up "$LOAD_WEB_REPLICAS" "$LOAD_WORKER_REPLICAS" mv "$output_dir/targets-current.json" "$output_dir/targets-after-recovery.json" wait_for_alert_clear "$drill_instance" wait_for_webhook_status \ "$drill_instance" resolved "$output_dir/alert-webhooks-complete.json" curl --fail --silent --show-error "$alertmanager_url/api/v2/alerts" \ >"$output_dir/alertmanager-alerts-after-recovery.json" database_snapshot "$output_dir/database-after.txt" if ! diff -u "$output_dir/database-before.txt" "$output_dir/database-after.txt" \ >"$output_dir/database-diff.txt"; then echo "The observability drill changed tracked database counts." >&2 exit 1 fi "${compose[@]}" ps -a >"$output_dir/compose-after.txt" mapfile -t compose_container_ids < <("${compose[@]}" ps --all --quiet) docker inspect "${compose_container_ids[@]}" | jq '[ .[] | { container: (.Name | ltrimstr("/")), configured_image: .Config.Image, image_id: .Image, service: .Config.Labels["com.docker.compose.service"], state: .State.Status } ]' >"$output_dir/images.json" jq -n \ --arg run_id "$LABEL" \ --arg drill_instance "$drill_instance" \ --arg prometheus_url "$prometheus_url" \ --arg alertmanager_url "$alertmanager_url" \ --arg grafana_url "$grafana_url" \ --argjson web_replicas "$LOAD_WEB_REPLICAS" \ --argjson worker_replicas "$LOAD_WORKER_REPLICAS" \ '{ run_id: $run_id, web_replicas: $web_replicas, worker_replicas: $worker_replicas, direct_targets_up_before_and_after: true, induced_instance: $drill_instance, firing_webhook_observed: true, resolved_webhook_observed: true, database_count_diff_bytes: 0, prometheus_url: $prometheus_url, alertmanager_url: $alertmanager_url, grafana_url: $grafana_url, grafana_dashboard_path: "/d/wnh-overview/overview" }' >"$output_dir/summary.json" trap - EXIT HUP INT TERM printf 'Observability evidence: %s\n' "$output_dir" printf 'Prometheus: %s\nAlertmanager: %s\nGrafana: %s/d/wnh-overview/overview\n' \ "$prometheus_url" "$alertmanager_url" "$grafana_url" printf 'Grafana user: %s; its random password remains only in ignored .env.load.\n' \ "$OBSERVABILITY_GRAFANA_ADMIN_USER"