"""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()