veza/infra/ansible/playbooks/postgres_ha.yml
senke bf31a91ae6
Some checks failed
Veza CI / Frontend (Web) (push) Failing after 16m6s
Veza CI / Notify on failure (push) Successful in 11s
E2E Playwright / e2e (full) (push) Successful in 19m59s
Veza CI / Rust (Stream Server) (push) Successful in 4m57s
Security Scan / Secret Scanning (gitleaks) (push) Successful in 49s
Veza CI / Backend (Go) (push) Successful in 6m4s
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-28 00:51:00 +02:00

147 lines
5.1 KiB
YAML

# Postgres HA playbook — provisions 3 Incus containers on the
# `incus_hosts` group (lab/staging/prod) and lays down the
# pg_auto_failover formation across them.
#
# Topology:
# - pgaf-monitor — the state machine (single instance)
# - pgaf-primary — first data node, becomes primary at first boot
# - pgaf-replica — second data node, becomes hot-standby
#
# v1.0.9 Day 6 — single host (R720 lab) for now. W2 day 7+ moves
# the data nodes onto separate physical hosts when Hetzner standby
# is provisioned. The formation works the same either way.
#
# Run with:
# ansible-playbook -i inventory/lab.yml playbooks/postgres_ha.yml --check
# ansible-playbook -i inventory/lab.yml playbooks/postgres_ha.yml
---
- name: Provision Incus containers for the Postgres formation + pgbouncer
hosts: incus_hosts
become: true
gather_facts: true
tasks:
- name: Launch pgaf-monitor + pgaf-primary + pgaf-replica + pgaf-pgbouncer
ansible.builtin.shell:
cmd: |
set -e
for ct in pgaf-monitor pgaf-primary pgaf-replica pgaf-pgbouncer; do
if ! incus info "$ct" >/dev/null 2>&1; then
incus launch images:ubuntu/22.04 "$ct"
# Wait for cloud-init / network to settle.
for _ in $(seq 1 30); do
if incus exec "$ct" -- cloud-init status 2>/dev/null | grep -q "status: done"; then
break
fi
sleep 1
done
# Install python3 inside the container so Ansible can
# speak to it via the incus connection plugin.
incus exec "$ct" -- apt-get update
incus exec "$ct" -- apt-get install -y python3 python3-apt
fi
done
args:
executable: /bin/bash
register: provision_result
changed_when: "'incus launch' in provision_result.stdout"
tags: [postgres_ha, pgbouncer, provision]
- name: Refresh inventory so the new containers are reachable via the incus connection
ansible.builtin.meta: refresh_inventory
- name: Apply common baseline to the formation containers
hosts: postgres_ha
become: true
gather_facts: true
roles:
- common
- name: Bring up the pg_auto_failover monitor first (formation depends on it)
hosts: postgres_ha_monitor
become: true
gather_facts: true
roles:
- postgres_ha
- name: Bring up the data nodes (primary registers first, replica registers second)
hosts: postgres_ha_nodes
become: true
gather_facts: true
serial: 1 # primary must register before replica — pg_auto_failover assigns roles by registration order
roles:
- postgres_ha
# v1.0.9 Day 7: PgBouncer fronts the formation. Common baseline first
# (SSH + node_exporter + fail2ban), then the pgbouncer role itself.
- name: Apply common baseline to the pgbouncer container
hosts: pgbouncer
become: true
gather_facts: true
roles:
- common
- name: Install + configure PgBouncer pointing at the formation
hosts: pgbouncer
become: true
gather_facts: true
roles:
- pgbouncer
# v1.0.9 Day 8: pgBackRest on the data nodes (archive_command + full
# / diff timers + stanza-create from whoever is primary).
- name: Install + configure pgBackRest on the data nodes
hosts: postgres_ha_nodes
become: true
gather_facts: true
roles:
- pgbackrest
# Drill installer — runs on the Incus host so it can `incus launch`
# the ephemeral restore container. Pushes dr-drill.sh to
# /usr/local/bin, ensures the textfile-collector dir exists for
# node_exporter, and wires the weekly drill timer.
- name: Install dr-drill on the Incus host
hosts: incus_hosts
become: true
gather_facts: true
tasks:
- name: Push dr-drill.sh to /usr/local/bin
ansible.builtin.copy:
src: ../../../scripts/dr-drill.sh
dest: /usr/local/bin/dr-drill.sh
owner: root
group: root
mode: "0755"
tags: [pgbackrest, drill]
- name: Ensure node_exporter textfile collector dir
ansible.builtin.file:
path: /var/lib/node_exporter/textfile_collector
state: directory
owner: node_exporter
group: node_exporter
mode: "0755"
tags: [pgbackrest, drill]
- name: Render dr-drill systemd service + timer
ansible.builtin.template:
src: ../roles/pgbackrest/templates/{{ item.src }}
dest: "{{ item.dest }}"
owner: root
group: root
mode: "0644"
loop:
- { src: pgbackrest-drill.service.j2, dest: /etc/systemd/system/pgbackrest-drill.service }
- { src: pgbackrest-drill.timer.j2, dest: /etc/systemd/system/pgbackrest-drill.timer }
tags: [pgbackrest, drill]
vars:
pgbackrest_stanza: "{{ hostvars[groups['postgres_ha_nodes'][0]]['pgbackrest_stanza'] | default('veza') }}"
pgbackrest_drill_schedule: "{{ hostvars[groups['postgres_ha_nodes'][0]]['pgbackrest_drill_schedule'] | default('Sun *-*-* 04:00:00') }}"
- name: Enable + start drill timer
ansible.builtin.systemd:
name: pgbackrest-drill.timer
state: started
enabled: true
daemon_reload: true
tags: [pgbackrest, drill]