server-up/tools/backup_rondgang.py
Ramon 504bb310ee
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 16m26s
v0.7.96-beta - backups echt getest, terugrollen, pullen, reconfigure en purge
- tools/backup_rondgang.py draait backup en terugzetten tegen een echte
  Docker-daemon in de CI: postgres-sidecar vullen, backuppen, alles weggooien,
  terugzetten, en controleren dat de database daarna nog kan schrijven. Alle
  bestaande backuptests vervangen Docker door een nep, dus die keten was nooit
  bewezen
- daardoor gevonden: het terugzetten gaf de appdata de verkeerde eigenaar. Het
  archief bevat de echte uid (postgres draait als 70 en zegt dat nergens in de
  metadata); die gaat nu voor op PUID en op onze eigen uid
- een mislukte --update rolt terug naar de commit van ervoor en start die
  opnieuw, in plaats van een stilstaande server en een rijtje commando's
- met een registry-image (SU_IMAGE) wordt er gepulld in plaats van gebouwd
- nieuw --reconfigure: bind, poort, account of hoofdmap wijzigen zonder de
  broncode aan te raken
- nieuw --uninstall --purge: ook het volume, de gegevensmap en het account,
  per onderdeel gevraagd. Zonder --purge somt --uninstall nu op wat blijft
- --dir wordt op een volledig pad gecontroleerd, het tijdelijke bestand voor
  het beheerdersaccount is niet meer voorspelbaar, en er gaat nog een
  rondgang naar de docker-daemon in plaats van twee

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-03 15:45:14 +02:00

200 lines
7.6 KiB
Python

"""Backup maken en terugzetten met een échte Docker-daemon.
Elke backuptest in `tests/` vervangt Docker door een nepversie — vijftien keer
in `test_backups.py` alleen al. Dat toetst de logica, maar niet de keten waar
het om gaat: `pg_dump` via `docker exec`, het archief, het terugzetten, en of de
database daarna nog opkomt met het eigenaarschap dat wij hebben gezet.
Juist daar betekent fout zijn *gegevensverlies*, en juist daar was niets van
bewezen. Dit script draait die rondgang wel echt, in de CI, op het image dat ook
in productie draait.
python tools/backup_rondgang.py /pad/naar/werkmap
De werkmap moet op de host op hetzelfde pad bestaan als hier: de postgres-
container krijgt zijn datamap als bind mount, en die wordt door de daemon op de
host opgezocht — niet in deze container.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
STACK = "proef"
WACHTWOORD = "rondgang-wachtwoord"
COMPOSE = """\
services:
{stack}-db:
image: postgres:16-alpine
container_name: {stack}-db
environment:
- POSTGRES_USER={stack}
- POSTGRES_DB={stack}
- POSTGRES_PASSWORD=${{DB_PASSWORD}}
volumes:
- {appdata}/{stack}-db:/var/lib/postgresql/data
"""
def kop(tekst: str) -> None:
print(f"\n\033[1m{tekst}\033[0m", flush=True)
def ok(tekst: str) -> None:
print(f" \033[32m✔\033[0m {tekst}", flush=True)
def mislukt(tekst: str) -> None:
print(f" \033[31m✖\033[0m {tekst}", file=sys.stderr, flush=True)
raise SystemExit(1)
def psql(sql: str, verwacht_fout: bool = False) -> str:
"""Voer SQL uit in de draaiende container."""
r = subprocess.run(
["docker", "exec", "-e", f"PGPASSWORD={WACHTWOORD}", f"{STACK}-db",
"psql", "-U", STACK, "-d", STACK, "-tAc", sql],
capture_output=True, text=True, timeout=120)
if r.returncode != 0 and not verwacht_fout:
mislukt(f"psql mislukte: {(r.stderr or r.stdout).strip()[:300]}")
return (r.stdout or "").strip()
def wacht_op_postgres(pogingen: int = 60) -> None:
for _ in range(pogingen):
r = subprocess.run(["docker", "exec", f"{STACK}-db",
"pg_isready", "-U", STACK],
capture_output=True, text=True)
if r.returncode == 0:
return
time.sleep(2)
mislukt("postgres kwam niet op")
def main() -> None:
if len(sys.argv) < 2:
mislukt("Geef een werkmap op die ook op de host bestaat.")
basis = Path(sys.argv[1]).resolve()
lib = basis / "stacks"
appdata = basis / "appdata"
backupmap = basis / "backups"
for d in (lib, appdata, backupmap):
d.mkdir(parents=True, exist_ok=True)
os.environ.update({
"SU_CONFIG": str(basis / "config.json"),
"SU_AUDIT": str(basis / "audit.db"),
"LIBRARY_DIR": str(lib),
"DATA_DIR": str(appdata),
"BACKUP_DIR": str(backupmap),
})
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
from core import backups, docker # noqa: E402
stackmap = lib / STACK
stackmap.mkdir(parents=True, exist_ok=True)
(stackmap / "docker-compose.yml").write_text(
COMPOSE.format(stack=STACK, appdata=appdata), encoding="utf-8")
(stackmap / ".env").write_text(f"DB_PASSWORD={WACHTWOORD}\n", encoding="utf-8")
(stackmap / ".serverup.json").write_text(json.dumps({
"source": STACK,
"values": {"appdata_dir": str(appdata), "service_name": STACK},
}), encoding="utf-8")
try:
kop("1. Stack starten en vullen")
if docker.compose_up(stackmap, name=STACK) != 0:
mislukt("de stack startte niet")
wacht_op_postgres()
psql("CREATE TABLE proef (id int, tekst text);")
psql("INSERT INTO proef VALUES (1, 'dit moet de rondgang overleven');")
assert psql("SELECT tekst FROM proef;") == "dit moet de rondgang overleven"
ok("tabel aangemaakt en gevuld")
datamap = appdata / f"{STACK}-db"
ok(f"appdata staat in {datamap} (eigenaar uid {datamap.stat().st_uid})")
# Een tweede appdata-map, van de app zelf. Die heeft geen container die
# hem bij het starten rechtzet — anders dan de datamap van postgres,
# die het officiële image zelf chownt. Alleen hier kun je dus zien of
# het terugzetten het eigenaarschap bewaart.
appmap = appdata / STACK
appmap.mkdir(parents=True, exist_ok=True)
(appmap / "instellingen.txt").write_text("van de app zelf\n", encoding="utf-8")
for p in (appmap, appmap / "instellingen.txt"):
os.chown(p, 1234, 5678)
ok(f"tweede appdata-map {appmap} op uid 1234:5678 gezet")
kop("2. Backup maken")
meta = backups.create(STACK, reden="rondgang", log_fn=lambda m: print(f" · {m}"))
archief = backupmap / meta["file"]
if not archief.is_file():
mislukt("er is geen archief geschreven")
import tarfile
with tarfile.open(archief) as tar:
namen = tar.getnames()
if f"dumps/{STACK}-db.sql" not in namen:
mislukt(f"geen pg_dump in het archief: {[n for n in namen[:20]]}")
if not any(n.startswith(f"appdata/{STACK}-db") for n in namen):
mislukt("de appdata zit niet in het archief")
if meta.get("skipped"):
mislukt(f"er is data overgeslagen: {meta['skipped'][:5]}")
ok(f"archief bevat de dump én de appdata ({len(namen)} items)")
kop("3. Alles kwijtraken")
psql("DROP TABLE proef;")
docker.compose_down(stackmap, name=STACK)
shutil.rmtree(datamap, ignore_errors=True)
if datamap.exists():
mislukt("de appdata is niet weg te krijgen")
ok("tabel weg, container weg, appdata weg")
kop("4. Terugzetten")
gelukt, melding = backups.restore(STACK, meta["file"],
log_fn=lambda m: print(f" · {m}"))
if not gelukt:
mislukt(f"terugzetten mislukte: {melding}")
ok(melding)
kop("5. Controleren")
if not datamap.is_dir():
mislukt("de appdata is niet teruggezet")
# Het archief is het enige dat weet van wie de appdata was: een
# postgres-container draait als uid 70 en zegt dat nergens in de
# metadata. Zonder dit kreeg alles de uid van wie er uitpakte.
if not (appmap / "instellingen.txt").is_file():
mislukt("de tweede appdata-map is niet teruggezet")
st = (appmap / "instellingen.txt").stat()
if (st.st_uid, st.st_gid) != (1234, 5678):
mislukt(f"eigenaar niet bewaard: uid {st.st_uid}:{st.st_gid} "
f"in plaats van 1234:5678")
ok("eigenaar van de appdata bewaard gebleven (uid 1234:5678)")
wacht_op_postgres()
terug = psql("SELECT tekst FROM proef;")
if terug != "dit moet de rondgang overleven":
mislukt(f"de gegevens zijn niet terug: {terug!r}")
ok("de rij staat er weer")
# Dit is de vraag waar het om draait: kan de database ook nog schríjven?
# Een datamap van de verkeerde eigenaar geeft pas bij de eerste schrijf
# een fout, niet bij het lezen.
psql("INSERT INTO proef VALUES (2, 'en schrijven kan ook nog');")
if psql("SELECT count(*) FROM proef;") != "2":
mislukt("de database kan niet meer schrijven na het terugzetten")
ok("de database kan ook nog schrijven")
kop("Rondgang geslaagd")
finally:
subprocess.run(["docker", "rm", "-f", f"{STACK}-db"],
capture_output=True, timeout=120)
shutil.rmtree(basis, ignore_errors=True)
main()