From b61dfd968e4197bab36899ceb46a370da3addfe0 Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Fri, 14 Aug 2026 12:00:00 +0000 Subject: [PATCH] feat(backup): monthly restore drill that proves the backups are restorable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the Borg archives into a throwaway postgres inside the pod and passes only when rows actually land — the pg_restore exit code is not proof, counted rows are. Production is never touched; the repos are only read. Automated rather than a documented cadence: a check nobody performs is the same mistake as an untested backup, one level up. Runs on the 4th at 04:20, after the nightly jobs. Verified manually before commit (synapse 31908 rows, MAS 16085, wiki 251). Co-Authored-By: Claude Opus 4.8 --- apps/production/kustomization.yaml | 2 + apps/production/restore-drill.yaml | 121 +++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 apps/production/restore-drill.yaml diff --git a/apps/production/kustomization.yaml b/apps/production/kustomization.yaml index 7cd3141..e9b7356 100644 --- a/apps/production/kustomization.yaml +++ b/apps/production/kustomization.yaml @@ -40,6 +40,8 @@ resources: # Backup zur Hetzner Storage Box (Issues #6 + #15) - synapse-backup-secret.yaml - synapse-backup.yaml + # Monatliche Restore-Probe: spielt die Sicherungen isoliert zurueck (#0030) + - restore-drill.yaml # Automatisierte TURN-Secret-Rotation (Issue #38) - turn-secret-rotation-secret.yaml - turn-secret-rotation.yaml diff --git a/apps/production/restore-drill.yaml b/apps/production/restore-drill.yaml new file mode 100644 index 0000000..ec2288b --- /dev/null +++ b/apps/production/restore-drill.yaml @@ -0,0 +1,121 @@ +# Monatliche Restore-Probe (#0030): spielt die Borg-Sicherungen in eine Wegwerf-Postgres +# IM POD zurueck und besteht nur, wenn wirklich Zeilen ankommen. Die Produktion wird nicht +# angefasst - es wird ausschliesslich aus den Repos gelesen. +# +# Warum automatisch statt "einmal im Quartal dran denken": eine Sicherung, die nie +# zurueckgespielt wurde, ist eine Vermutung (#0030) - ein Pruefrhythmus, den niemand +# ausfuehrt, ist genau derselbe Fehler eine Ebene hoeher. +# +# Abgedeckt sind die unersetzlichen Daten: synapse + matrixauthenticationservice und das +# Wiki. Authentik ist bewusst nicht Teil des automatischen Laufs (Flows/Provider liegen +# als Blueprints deklarativ im Repo, die DB ist also weitgehend reproduzierbar); dafuer +# gibt es die Stufe 3 in notfallhandbuch/notfall.sh auf Zuruf. +# +# Fehlschlag = der Job schlaegt fehl. Alarmierung dazu: threadnet-operating, +# monitoring/prometheus/alerts.yml (BackupJobFailed / RestoreDrillStale). +apiVersion: batch/v1 +kind: CronJob +metadata: + name: restore-drill + namespace: matrix +spec: + # 4. des Monats, 04:20 - deutlich nach den naechtlichen Backups (03:00/03:15/03:30), + # damit die Probe den frischen Stand zieht. + schedule: "20 4 4 * *" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 1 + template: + metadata: + labels: + app.kubernetes.io/name: synapse-backup # NetworkPolicy/Egress wie die Backups + app.kubernetes.io/component: restore-drill + spec: + restartPolicy: OnFailure + containers: + - name: drill + image: rohana.axion1337.de/sorb/axion-backup:v2 + env: + - name: BORG_PASSPHRASE + valueFrom: + secretKeyRef: + name: synapse-backup-credentials + key: borg-passphrase + - name: SSH_PRIVATE_KEY_FILE + value: /secrets/ssh/ssh-private-key + - name: SSH_KNOWN_HOSTS_FILE + value: /secrets/known-hosts/known_hosts + command: ["sh", "-c"] + args: + - | + set -eu + export BORG_RSH="ssh -i $SSH_PRIVATE_KEY_FILE -o UserKnownHostsFile=$SSH_KNOWN_HOSTS_FILE -o StrictHostKeyChecking=yes" + export PGDATA=/scratch/pgdata + mkdir -p "$PGDATA" /scratch/sock + chown postgres:postgres "$PGDATA" /scratch/sock + echo "[drill] Wegwerf-Postgres starten" + gosu postgres initdb -U postgres >/dev/null 2>&1 + gosu postgres pg_ctl -D "$PGDATA" -o "-k /scratch/sock -h ''" -w start >/dev/null + # repo:datenbanken + for pair in "synapse-backup:synapse matrixauthenticationservice" "wikijs-backup:wiki"; do + repo="${pair%%:*}"; dbs="${pair#*:}" + export BORG_REPO="ssh://u641795@u641795.your-storagebox.de:23/./${repo}" + cd /scratch && rm -rf scratch + latest=$(borg list --last 1 --format '{archive}' "$BORG_REPO") + echo "[drill] ${repo}: Archiv ${latest}" + borg extract "$BORG_REPO::${latest}" scratch/dumps + for db in $dbs; do + # pg_restore/psql laufen als root und verbinden per Socket als + # postgres (initdb setzt lokal 'trust') - sonst scheitert das Lesen + # der root-eigenen Dump-Dateien. + dropdb -h /scratch/sock -U postgres --if-exists "$db" + createdb -h /scratch/sock -U postgres "$db" + rc=0 + pg_restore -h /scratch/sock -U postgres -d "$db" --no-owner \ + --no-privileges "scratch/dumps/${db}.dump" >/scratch/restore.log 2>&1 || rc=$? + [ "$rc" != "0" ] && { echo "[drill] pg_restore Code $rc:"; tail -3 /scratch/restore.log; } + psql -h /scratch/sock -U postgres -d "$db" -q -c 'ANALYZE;' >/dev/null 2>&1 + rows=$(psql -h /scratch/sock -U postgres -d "$db" -tA \ + -c 'select coalesce(sum(n_live_tup),0) from pg_stat_user_tables;') + echo "[drill] ${db}: ${rows} Zeilen" + # Der Exitcode von pg_restore ist kein Beweis (es warnt gern) - + # gezaehlte Zeilen sind einer. + if [ "$rows" -le 0 ]; then + echo "[drill] FEHLER: ${db} ist nach dem Restore LEER - Sicherung unbrauchbar!" + exit 1 + fi + done + done + echo "[drill] OK - alle Sicherungen liessen sich zurueckspielen" + volumeMounts: + - name: ssh-key + mountPath: /secrets/ssh + readOnly: true + - name: known-hosts + mountPath: /secrets/known-hosts + readOnly: true + - name: scratch + mountPath: /scratch + resources: + requests: + memory: 256Mi + cpu: 100m + limits: + memory: 1Gi + volumes: + - name: ssh-key + secret: + secretName: synapse-backup-credentials + defaultMode: 0400 + items: + - key: ssh-private-key + path: ssh-private-key + - name: known-hosts + configMap: + name: synapse-backup-known-hosts + - name: scratch + emptyDir: + sizeLimit: 4Gi