All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 16m13s
De rondgang doorliep alle vijf de stappen goed en de CI-stap viel er daarna alsnog op om, zonder melding: tussen "Rondgang geslaagd" en het einde zaten tweeenhalve seconde en verder niets. Dat is het opruimblok. - opruimen kan de uitslag niet meer bepalen; mislukt het, dan staat dat er - het script eindigt expliciet met exitcode 0 - de CI drukt de exitcode van de stap af, want zonder dat getal was niet te achterhalen waar een rode job vandaan kwam terwijl het scherm groen was De postgres-race uit v0.8.06 is bevestigd opgelost: de rondgang liep in een keer door. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
240 lines
9.4 KiB
Python
240 lines
9.4 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):
|
|
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)
|