veza/scripts/dr-drill.sh

193 lines
8.1 KiB
Bash
Raw Normal View History

feat(infra): pgbackrest role + dr-drill + Prometheus backup alerts (W2 Day 8) ROADMAP_V1.0_LAUNCH.md §Semaine 2 day 8 deliverable: - Postgres backups land in MinIO via pgbackrest - dr-drill restores them weekly into an ephemeral Incus container and asserts the data round-trips - Prometheus alerts fire when the drill fails OR when the timer has stopped firing for >8 days Cadence: full — weekly (Sun 02:00 UTC, systemd timer) diff — daily (Mon-Sat 02:00 UTC, systemd timer) WAL — continuous (postgres archive_command, archive_timeout=60s) drill — weekly (Sun 04:00 UTC — runs 2h after the Sun full so the restore exercises fresh data) RPO ≈ 1 min (archive_timeout). RTO ≤ 30 min (drill measures actual restore wall-clock). Files: infra/ansible/roles/pgbackrest/ defaults/main.yml — repo1-* config (MinIO/S3, path-style, aes-256-cbc encryption, vault-backed creds), retention 4 full / 7 diff / 4 archive cycles, zstd@3 compression. The role's first task asserts the placeholder secrets are gone — refuses to apply until the vault carries real keys. tasks/main.yml — install pgbackrest, render /etc/pgbackrest/pgbackrest.conf, set archive_command on the postgres instance via ALTER SYSTEM, detect role at runtime via `pg_autoctl show state --json`, stanza-create from primary only, render + enable systemd timers (full + diff + drill). templates/pgbackrest.conf.j2 — global + per-stanza sections; pg1-path defaults to the pg_auto_failover state dir so the role plugs straight into the Day 6 formation. templates/pgbackrest-{full,diff,drill}.{service,timer}.j2 — systemd units. Backup services run as `postgres`, drill service runs as `root` (needs `incus`). RandomizedDelaySec on every timer to absorb clock skew + node collision risk. README.md — RPO/RTO guarantees, vault setup, repo wiring, operational cheatsheet (info / check / manual backup), restore procedure documented separately as the dr-drill. scripts/dr-drill.sh Acceptance script for the day. Sequence: 0. pre-flight: required tools, latest backup metadata visible 1. launch ephemeral `pg-restore-drill` Incus container 2. install postgres + pgbackrest inside, push the SAME pgbackrest.conf as the host (read-only against the bucket by pgbackrest semantics — the same s3 keys get reused so the drill exercises the production credential path) 3. `pgbackrest restore` — full + WAL replay 4. start postgres, wait for pg_isready 5. smoke query: SELECT count(*) FROM users — must be ≥ MIN_USERS_EXPECTED 6. write veza_backup_drill_* metrics to the textfile-collector 7. teardown (or --keep for postmortem inspection) Exit codes 0/1/2 (pass / drill failure / env problem) so a Prometheus runner can plug in directly. config/prometheus/alert_rules.yml — new `veza_backup` group: - BackupRestoreDrillFailed (critical, 5m): the last drill reported success=0. Pages because a backup we haven't proved restorable is dette technique waiting for a disaster. - BackupRestoreDrillStale (warning, 1h after >8 days): the drill timer has stopped firing. Catches a broken cron / unit / runner before the failure-mode alert above ever sees data. Both annotations include a runbook_url stub (veza.fr/runbooks/...) — those land alongside W2 day 10's SLO runbook batch. infra/ansible/playbooks/postgres_ha.yml Two new plays: 6. apply pgbackrest role to postgres_ha_nodes (install + config + full/diff timers on every data node; pgbackrest's repo lock arbitrates collision) 7. install dr-drill on the incus_hosts group (push /usr/local/bin/dr-drill.sh + render drill timer + ensure /var/lib/node_exporter/textfile_collector exists) Acceptance verified locally: $ ansible-playbook -i inventory/lab.yml playbooks/postgres_ha.yml \ --syntax-check playbook: playbooks/postgres_ha.yml ← clean $ python3 -c "import yaml; yaml.safe_load(open('config/prometheus/alert_rules.yml'))" YAML OK $ bash -n scripts/dr-drill.sh syntax OK Real apply + drill needs the lab R720 + a populated MinIO bucket + the secrets in vault — operator's call. Out of scope (deferred per ROADMAP §2): - Off-site backup replica (B2 / Bunny.net) — v1.1+ - Logical export pipeline for RGPD per-user dumps — separate feature track, not a backup-system concern - PITR admin UI — CLI-only via `--type=time` for v1.0 - pgbackrest_exporter Prometheus integration — W2 day 9 alongside the OTel collector Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:51:00 +00:00
#!/usr/bin/env bash
# dr-drill.sh — Postgres backup restore drill.
#
# Restores the most recent pgBackRest full+WAL into an ephemeral
# Incus container, runs a smoke query against the recovered DB,
# tears the container down, and writes a textfile metric for the
# Prometheus alert BackupRestoreDrillFailed.
#
# Acceptance for ROADMAP_V1.0_LAUNCH.md §Semaine 2 day 8.
#
# Usage:
# bash scripts/dr-drill.sh [--keep]
#
# Env overrides:
# PGBACKREST_STANZA default: veza
# PGBACKREST_SECRETS default: /etc/pgbackrest/pgbackrest.conf
# (mounted into the drill container so
# the same S3 creds + cipher pass apply)
# POSTGRES_VERSION default: 16
# DRILL_CONTAINER default: pg-restore-drill
# DRILL_METRICS_FILE default: /var/lib/node_exporter/textfile_collector/pgbackrest_drill.prom
# MIN_USERS_EXPECTED default: 1 ; set higher when the seed grows
#
# Exit codes:
# 0 — drill passed (restore + smoke query OK)
# 1 — drill failed (restore error, smoke query failure, or
# short user count)
# 2 — environment problem (missing tool, no backups, can't
# reach the Incus host)
set -euo pipefail
PGBACKREST_STANZA=${PGBACKREST_STANZA:-veza}
PGBACKREST_CONF_HOST=${PGBACKREST_CONF_HOST:-/etc/pgbackrest/pgbackrest.conf}
POSTGRES_VERSION=${POSTGRES_VERSION:-16}
DRILL_CONTAINER=${DRILL_CONTAINER:-pg-restore-drill}
DRILL_METRICS_FILE=${DRILL_METRICS_FILE:-/var/lib/node_exporter/textfile_collector/pgbackrest_drill.prom}
DRILL_METRICS_TMP=${DRILL_METRICS_FILE}.tmp
MIN_USERS_EXPECTED=${MIN_USERS_EXPECTED:-1}
KEEP_CONTAINER=0
if [ "${1:-}" = "--keep" ]; then KEEP_CONTAINER=1; fi
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
fail() { log "FAIL: $*"; write_metric 0 "${1:-failed}" "${SECONDS}"; exit "${2:-1}"; }
require() { command -v "$1" >/dev/null 2>&1 || { log "missing tool: $1"; exit 2; } }
write_metric() {
local success="$1" reason="${2:-ok}" duration="${3:-0}"
local now
now=$(date +%s)
mkdir -p "$(dirname "$DRILL_METRICS_FILE")"
cat >"$DRILL_METRICS_TMP" <<EOF
# HELP veza_backup_drill_last_run_timestamp_seconds Unix epoch of the last drill attempt
# TYPE veza_backup_drill_last_run_timestamp_seconds gauge
veza_backup_drill_last_run_timestamp_seconds ${now}
# HELP veza_backup_drill_last_success Boolean (1=last drill succeeded, 0=failed)
# TYPE veza_backup_drill_last_success gauge
veza_backup_drill_last_success{stanza="${PGBACKREST_STANZA}",reason="${reason}"} ${success}
# HELP veza_backup_drill_last_duration_seconds Wall-clock seconds of the last drill
# TYPE veza_backup_drill_last_duration_seconds gauge
veza_backup_drill_last_duration_seconds ${duration}
EOF
mv "$DRILL_METRICS_TMP" "$DRILL_METRICS_FILE"
}
cleanup() {
if [ "$KEEP_CONTAINER" -eq 1 ]; then
log "leaving $DRILL_CONTAINER alive (--keep)"
return
fi
if incus info "$DRILL_CONTAINER" >/dev/null 2>&1; then
log "tearing down $DRILL_CONTAINER"
incus delete --force "$DRILL_CONTAINER" || true
fi
}
trap cleanup EXIT
# -----------------------------------------------------------------------------
# 0. Pre-flight.
# -----------------------------------------------------------------------------
require incus
require pgbackrest
require date
[ -f "$PGBACKREST_CONF_HOST" ] || fail "pgbackrest.conf not found at $PGBACKREST_CONF_HOST" 2
log "step 0: read latest backup metadata for stanza=$PGBACKREST_STANZA"
backup_info=$(pgbackrest --stanza="$PGBACKREST_STANZA" --output=text info 2>&1 || true)
echo "$backup_info" | sed 's/^/ /' >&2
if ! echo "$backup_info" | grep -q "full backup:"; then
fail "no full backup visible — has the stanza had time to run yet?" 2
fi
# -----------------------------------------------------------------------------
# 1. Provision the drill container.
# -----------------------------------------------------------------------------
log "step 1: launching $DRILL_CONTAINER (ephemeral Ubuntu 22.04)"
if incus info "$DRILL_CONTAINER" >/dev/null 2>&1; then
log " pre-existing container, tearing it down for a clean run"
incus delete --force "$DRILL_CONTAINER"
fi
incus launch images:ubuntu/22.04 "$DRILL_CONTAINER" -c security.privileged=true
# Wait for cloud-init.
for _ in $(seq 1 60); do
if incus exec "$DRILL_CONTAINER" -- cloud-init status 2>/dev/null | grep -q "status: done"; then
break
fi
sleep 1
done
# -----------------------------------------------------------------------------
# 2. Install postgres + pgbackrest inside, push the same config in
# (read-only against the bucket).
# -----------------------------------------------------------------------------
log "step 2: installing postgres + pgbackrest in $DRILL_CONTAINER"
incus exec "$DRILL_CONTAINER" -- bash -c "
set -e
apt-get update >/dev/null
apt-get install -y curl ca-certificates gnupg lsb-release >/dev/null
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /etc/apt/keyrings/postgresql.asc
echo 'deb [signed-by=/etc/apt/keyrings/postgresql.asc] https://apt.postgresql.org/pub/repos/apt jammy-pgdg main' \
> /etc/apt/sources.list.d/pgdg.list
apt-get update >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y \
postgresql-${POSTGRES_VERSION} \
postgresql-client-${POSTGRES_VERSION} \
pgbackrest >/dev/null
systemctl stop postgresql@${POSTGRES_VERSION}-main || true
rm -rf /var/lib/postgresql/${POSTGRES_VERSION}/main
install -d -o postgres -g postgres -m 0700 /var/lib/postgresql/${POSTGRES_VERSION}/main
install -d -o postgres -g postgres -m 0750 /etc/pgbackrest
install -d -o postgres -g postgres -m 0750 /var/log/pgbackrest
"
incus file push "$PGBACKREST_CONF_HOST" "$DRILL_CONTAINER/etc/pgbackrest/pgbackrest.conf"
incus exec "$DRILL_CONTAINER" -- chown postgres:postgres /etc/pgbackrest/pgbackrest.conf
# Patch the conf so pg1-path points at the empty-dir we just made,
# and add `delta = y` for resumable restores. Stanza name and S3
# credentials carry over verbatim — the drill restores from the
# real prod repo (read-only via pgbackrest semantics).
incus exec "$DRILL_CONTAINER" -- bash -c "
sed -i 's|^pg1-path =.*|pg1-path = /var/lib/postgresql/${POSTGRES_VERSION}/main|' /etc/pgbackrest/pgbackrest.conf
echo 'delta = y' >> /etc/pgbackrest/pgbackrest.conf
"
# -----------------------------------------------------------------------------
# 3. Restore.
# -----------------------------------------------------------------------------
log "step 3: pgbackrest restore (latest backup, full WAL replay)"
incus exec "$DRILL_CONTAINER" -- sudo -u postgres \
pgbackrest --stanza="$PGBACKREST_STANZA" --log-level-console=info restore \
|| fail "restore failed" 1
# -----------------------------------------------------------------------------
# 4. Start postgres + smoke query.
# -----------------------------------------------------------------------------
log "step 4: starting postgres + waiting for ready"
incus exec "$DRILL_CONTAINER" -- bash -c "
systemctl start postgresql@${POSTGRES_VERSION}-main
for i in \$(seq 1 30); do
if sudo -u postgres pg_isready -p 5432 >/dev/null 2>&1; then
break
fi
sleep 1
done
"
if ! incus exec "$DRILL_CONTAINER" -- sudo -u postgres pg_isready -p 5432 >/dev/null 2>&1; then
fail "postgres did not become ready inside drill container" 1
fi
log "step 5: smoke query — SELECT count(*) FROM users"
users_count=$(incus exec "$DRILL_CONTAINER" -- sudo -u postgres \
psql -At -d veza -c 'select count(*) from users' 2>&1 || true)
log "users.count = $users_count (expecting >= $MIN_USERS_EXPECTED)"
if ! [[ "$users_count" =~ ^[0-9]+$ ]]; then
fail "users count is not numeric: '$users_count' (table missing? wrong db?)" 1
fi
if [ "$users_count" -lt "$MIN_USERS_EXPECTED" ]; then
fail "users count $users_count < expected $MIN_USERS_EXPECTED — backup may be broken" 1
fi
# -----------------------------------------------------------------------------
# 6. Verdict.
# -----------------------------------------------------------------------------
write_metric 1 "ok" "$SECONDS"
log "PASS: drill completed in ${SECONDS}s, users=$users_count"
exit 0