"""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): return 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) def psql(sql: str) -> str: """Voer SQL uit in de draaiende container.""" r = _psql(sql) if r.returncode != 0: mislukt(f"psql mislukte: {(r.stderr or r.stdout).strip()[:300]}") return (r.stdout or "").strip() def wacht_op_postgres(pogingen: int = 60) -> None: """Wacht tot psql het écht doet — niet tot pg_isready dat zegt. Het officiële postgres-image start bij een eerste run eerst een tijdelijke server op de socket om de init-scripts te draaien, stopt die daarna, en start pas dan de echte. `pg_isready` zegt tijdens die tijdelijke server al "ok". Kwam de volgende opdracht net in het gat daarna, dan was er geen socket meer: "connection to server on socket ... No such file or directory". Een race, dus soms goed en soms niet. Twee keer achter elkaar een geslaagde query is het antwoord: één toevallige treffer op de tijdelijke server overleeft dat niet. """ goed_achtereen = 0 laatste = "" for _ in range(pogingen): r = _psql("SELECT 1") if r.returncode == 0: goed_achtereen += 1 if goed_achtereen >= 2: return else: goed_achtereen = 0 laatste = (r.stderr or r.stdout or "").strip().splitlines()[:1] time.sleep(2) mislukt(f"postgres kwam niet op: {laatste}") 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") # Een afgebroken of geannuleerde vorige run draait zijn opruimblok niet. # Een achtergebleven container met een half geïnitialiseerde datamap # levert daarna raadselachtige fouten op. subprocess.run(["docker", "rm", "-f", f"{STACK}-db"], capture_output=True, timeout=120) if docker.compose_up(stackmap, log_fn=lambda m: print(f" · {m}"), 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: # Opruimen mag de uitslag niet bepalen. De rondgang was hierboven al # geslaagd of gefaald; een container die zich niet laat verwijderen of # een map die vastzit is vervelend, maar zegt niets over backups. # Eerder kon een fout hier de hele stap laten vallen zónder melding: # "Rondgang geslaagd" op het scherm en toch een rode job. try: subprocess.run(["docker", "rm", "-f", f"{STACK}-db"], capture_output=True, timeout=120) except Exception as e: print(f" opruimen van de container mislukte: {e}", flush=True) try: shutil.rmtree(basis, ignore_errors=True) except Exception as e: print(f" opruimen van {basis} mislukte: {e}", flush=True) main() # Expliciet, zodat er geen twijfel over de afloop kan bestaan. raise SystemExit(0)