server-up/server-up/core/backups.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

871 lines
35 KiB
Python

"""Backups van stacks: maken, tonen, terugzetten en opruimen.
Een backup is een `.tar.gz` met drie takken, plus een `.json` met metadata
ernaast:
<stack>/ de compose, de .env en .serverup.json
appdata/ de mappen waar de app zijn gegevens bewaart
dumps/ een dump per database die de stack meebrengt
Die tweede en derde tak zijn er niet altijd geweest. Eerder bevatte een backup
alleen de stackmap, en die staat náást de appdata in plaats van eromheen — je
maakte dus een backup, kreeg een groen vinkje, en hield bij terugzetten een lege
app over. `BACKUP_APPDATA` kan de appdata er weer uit laten voor stacks waar dat
in de honderden gigabytes loopt.
De dumps zijn geen dubbelop: een tar van een draaiende database is een
inconsistente kopie omdat er tijdens het inpakken doorgeschreven wordt.
Terugzetten gebeurt met Pythons `tarfile` en `filter="data"` (PEP 706): dat
weigert absolute paden en `..` in archiefingangen. Met een kaal `tar xzf` kan
een geprepareerd archief buiten de stackmap schrijven.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import tarfile
import tempfile
import time
from pathlib import Path
import core as cfg
from core import diskspace, docker
SUFFIX = ".tar.gz"
META_SUFFIX = ".json"
# <stack>_<JJJJMMDD>_<UUMMSS>[-n].tar.gz — ruimer dan cfg.safe_name() omdat de
# tijdstempel er nog achter komt, maar even streng op padtekens.
_BACKUP_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,120}_\d{8}_\d{6}(-\d+)?$")
class BackupError(Exception):
pass
# ── Paden ────────────────────────────────────────────────────────────────────
def backup_dir() -> Path:
return Path(cfg.load()["BACKUP_DIR"]).resolve()
def _archive_path(bestandsnaam: str) -> Path:
"""Valideer een backupnaam en geef het volledige pad.
De naam komt uit een request, dus hij mag nooit uit BACKUP_DIR wijzen.
"""
naam = (bestandsnaam or "").strip()
if not naam.endswith(SUFFIX):
raise BackupError("Geen geldige backupnaam.")
kern = naam[: -len(SUFFIX)]
if not _BACKUP_RE.match(kern):
raise BackupError("Geen geldige backupnaam.")
bd = backup_dir()
pad = (bd / naam).resolve()
if not pad.is_relative_to(bd):
raise BackupError("Geen geldige backupnaam.")
return pad
def _stack_dir(stack: str) -> Path:
if not cfg.safe_name(stack):
raise BackupError("Ongeldige stacknaam.")
lib = Path(cfg.load()["LIBRARY_DIR"]).resolve()
d = (lib / stack).resolve()
if not d.is_relative_to(lib) or d == lib:
raise BackupError("Ongeldige stacknaam.")
return d
# ── Wat er naast de stackmap in moet ─────────────────────────────────────────
def _stack_meta(stack: str) -> dict:
mf = _stack_dir(stack) / ".serverup.json"
if not mf.exists():
return {}
try:
return json.loads(mf.read_text("utf-8")) or {}
except Exception:
return {}
def _voeg_toe(tar, map_: Path, arcnaam: str, log_fn=None) -> list[str]:
"""Pak een appdata-map in en meld wat er niet mee kon.
Eén onleesbaar bestand liet eerder de héle backup falen: de `tar.add` van de
map zat in dezelfde `try` als de rest. Dat is precies het scenario waarin je
een backup het hardst nodig hebt — Server Up draait niet meer als root, dus
appdata van een app met een eigen uid kan buiten bereik liggen.
Retourneert de paden die zijn overgeslagen. Die gaan in het log én in de
metadata: een archief dat er compleet uitziet maar het niet is, is erger dan
een archief dat zegt wat het mist.
"""
gemist: list[str] = []
# Bewust bestand voor bestand in plaats van `tar.add` te laten recursen: die
# breekt af op de eerste fout, en wat er dan al in het archief zat zou bij
# een tweede poging dubbel meegaan.
tar.add(map_, arcname=arcnaam, recursive=False)
for p in sorted(map_.rglob("*")):
rel = p.relative_to(map_)
try:
tar.add(p, arcname=f"{arcnaam}/{rel}", recursive=False)
except (OSError, tarfile.TarError) as e:
gemist.append(str(rel))
if log_fn and len(gemist) <= 5:
log_fn(f"Overgeslagen (niet leesbaar): {rel}{e}")
if gemist and log_fn:
log_fn(f"{len(gemist)} bestand(en) niet ingepakt; zie de metadata.")
return gemist
def appdata_mappen(stack: str) -> list[Path]:
"""De mappen waar deze stack zijn gegevens in bewaart.
De stackmap bevat alleen compose, .env en de metadata. De eigenlijke
gegevens staan in `appdata_dir` — standaard `/opt/serverup/appdata`, een
buurmap van de library en dus niet in het archief. Een backup zag er dus
compleet uit en gaf bij terugzetten een lege app.
Welke mappen dat zijn weten we uit de waarden die bij het installeren zijn
bewaard: `<appdata_dir>/<service_name>` en de sidecars daarnaast
(`…-db`, `…-redis`), het patroon uit docs/apps-maken.md.
"""
waarden = (_stack_meta(stack).get("values") or {})
basis_ruw = str(waarden.get("appdata_dir") or cfg.load().get("DATA_DIR") or "")
if not basis_ruw:
return []
basis = Path(basis_ruw).resolve()
# Een sjabloon met `appdata_dir: /` zou anders de hele schijf inpakken.
if basis == Path("/") or not basis.is_dir():
return []
naam = str(waarden.get("service_name") or stack).strip()
if not naam or "/" in naam:
return []
uit = []
for d in sorted(basis.iterdir()):
if d.is_dir() and (d.name == naam or d.name.startswith(naam + "-")):
uit.append(d)
return uit
# Databases waarvan een tar van de datamap geen bruikbare kopie is: die
# schrijven tijdens het inpakken door. Een dump wél.
_DUMPERS = {
"postgres": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
"postgis": ("POSTGRES_USER", "POSTGRES_DB", "POSTGRES_PASSWORD"),
"mariadb": ("MARIADB_USER", "MARIADB_DATABASE", "MARIADB_PASSWORD"),
"mysql": ("MYSQL_USER", "MYSQL_DATABASE", "MYSQL_PASSWORD"),
"mongo": (None, None, "MONGO_INITDB_ROOT_PASSWORD"),
}
def _soort(image: str) -> str:
kaal = (image or "").split("/")[-1].split(":")[0].lower()
for s in _DUMPERS:
if kaal.startswith(s):
return s
return ""
def _env_waarden(d: Path) -> dict[str, str]:
"""De waarden uit .env, om `${DB_PASSWORD}` uit compose op te lossen."""
uit = {}
for regel in docker.read_env(d).splitlines():
regel = regel.strip()
if regel and not regel.startswith("#") and "=" in regel:
k, v = regel.split("=", 1)
uit[k.strip()] = v.strip()
return uit
def _vul_in(waarde: str, env: dict[str, str]) -> str:
"""`${DB_PASSWORD}` of `$DB_PASSWORD` vervangen door de waarde uit .env."""
s = str(waarde or "")
for k, v in env.items():
s = s.replace("${" + k + "}", v).replace("$" + k, v)
return s
def databasediensten(stack: str) -> list[dict]:
"""De databasecontainers van deze stack, met wat er nodig is voor een dump."""
import yaml
d = _stack_dir(stack)
try:
doc = yaml.safe_load(docker.read_compose(d)) or {}
except Exception:
return []
env = _env_waarden(d)
uit = []
for snaam, svc in (doc.get("services") or {}).items():
if not isinstance(svc, dict):
continue
soort = _soort(svc.get("image", ""))
if not soort:
continue
omgeving = {}
for e in svc.get("environment") or []:
if isinstance(e, str) and "=" in e:
k, v = e.split("=", 1)
omgeving[k] = _vul_in(v, env)
gebruiker_k, db_k, wachtwoord_k = _DUMPERS[soort]
uit.append({
"service": snaam,
"container": svc.get("container_name") or snaam,
"soort": soort,
"gebruiker": omgeving.get(gebruiker_k, "") if gebruiker_k else "",
"database": omgeving.get(db_k, "") if db_k else "",
"wachtwoord": omgeving.get(wachtwoord_k, ""),
})
return uit
def _dump_commando(dienst: dict) -> tuple[list[str], dict]:
"""Het commando binnen de container, plus de omgeving ervoor."""
s, gb, db, ww = (dienst["soort"], dienst["gebruiker"],
dienst["database"], dienst["wachtwoord"])
if s in ("postgres", "postgis"):
return (["pg_dump", "--clean", "--if-exists", "-U", gb or "postgres",
"-d", db or gb or "postgres"], {"PGPASSWORD": ww})
if s in ("mariadb", "mysql"):
hulp = "mariadb-dump" if s == "mariadb" else "mysqldump"
return ([hulp, "-u", gb or "root", f"-p{ww}", "--single-transaction",
db or gb], {})
return (["sh", "-c",
f"mongodump --archive --username='{gb or 'app'}' "
f"--password='{ww}' --authenticationDatabase=admin"], {})
def maak_dumps(stack: str, tmp: Path, log_fn=None) -> list[Path]:
"""Schrijf per databasecontainer een dump naar `tmp`.
Draait de container niet, dan komt er geen dump én een waarschuwing. Stil
overslaan zou het ergste geval opleveren: een archief dat compleet lijkt.
"""
uit = []
for dienst in databasediensten(stack):
naam = dienst["container"]
cmd, extra_env = _dump_commando(dienst)
volledig = [docker._bin(), "exec"]
for k, v in extra_env.items():
volledig += ["-e", f"{k}={v}"]
volledig += [naam] + cmd
# text=False: een mongodump-archief is binair en zou in tekstmodus
# stukgaan op de decodering.
r = docker._run(volledig, capture_output=True, text=False, timeout=900)
if r.returncode != 0:
if log_fn:
rauw = r.stderr or b""
tekst = rauw.decode("utf-8", "replace") if isinstance(rauw, bytes) else str(rauw)
regels = tekst.strip().splitlines() or ["onbekende fout"]
log_fn(f"Geen dump van {naam}: {regels[-1]}")
continue
ext = "archive" if dienst["soort"] == "mongo" else "sql"
pad = tmp / f"{dienst['service']}.{ext}"
data = r.stdout if isinstance(r.stdout, bytes) else (r.stdout or "").encode()
if not data:
if log_fn:
log_fn(f"Geen dump van {naam}: lege uitvoer")
continue
pad.write_bytes(data)
uit.append(pad)
if log_fn:
log_fn(f"Dump van {naam}: {_leesbaar(len(data))}")
return uit
# ── Maken ────────────────────────────────────────────────────────────────────
def create(stack: str, reden: str = "handmatig", log_fn=None,
appdata: bool | None = None) -> dict:
"""Maak een backup van de stackmap, de appdata en de databases.
`appdata=False` beperkt het tot de stackmap zoals vroeger — nodig bij een
mediabibliotheek of een fotoarchief, waar de gegevens in de honderden
gigabytes lopen en een tar per keer geen zinnig middel is.
"""
bron = _stack_dir(stack)
if not bron.is_dir():
raise BackupError(f"Stack '{stack}' bestaat niet.")
bd = backup_dir()
bd.mkdir(parents=True, exist_ok=True)
neem_appdata = (cfg.load().get("BACKUP_APPDATA", True)
if appdata is None else bool(appdata))
data_mappen = appdata_mappen(stack) if neem_appdata else []
# Vóórdat we beginnen: past het er redelijkerwijs in? Loopt de schijf
# halverwege vol, dan blijft er een archief achter dat compleet lijkt.
genoeg, waarom = diskspace.ruimte_voor_backup([bron, *data_mappen], bd)
if not genoeg:
raise BackupError(waarom)
ts = time.strftime("%Y%m%d_%H%M%S")
naam = f"{stack}_{ts}{SUFFIX}"
doel = bd / naam
# Twee backups binnen dezelfde seconde (bv. handmatig vlak na een
# automatische backup vóór een update) zouden elkaar overschrijven.
teller = 2
while doel.exists():
naam = f"{stack}_{ts}-{teller}{SUFFIX}"
doel = bd / naam
teller += 1
if log_fn:
log_fn(f"Backup maken van {stack}")
overgeslagen: list[str] = []
try:
# Python's tarfile in plaats van het tar-commando: de returncode van dat
# commando werd eerder genegeerd, zodat een mislukte backup als succes
# gold.
with tempfile.TemporaryDirectory() as t:
# Een tar van een draaiende database levert een inconsistente
# kopie; een dump wel een bruikbare. Beide gaan mee — de dump om
# terug te zetten, de datamap voor het geval de dump ontbreekt.
dumps = maak_dumps(stack, Path(t), log_fn=log_fn)
with tarfile.open(doel, "w:gz") as tar:
tar.add(bron, arcname=stack)
for m in data_mappen:
if log_fn:
log_fn(f"Appdata: {m.name}")
overgeslagen.extend(
_voeg_toe(tar, m, f"appdata/{m.name}", log_fn=log_fn))
for dp in dumps:
tar.add(dp, arcname=f"dumps/{dp.name}")
except Exception as e:
doel.unlink(missing_ok=True)
raise BackupError(f"Backup mislukt: {e}") from e
meta = {"stack": stack, "file": naam, "ts": time.time(),
"size": doel.stat().st_size, "reason": reden,
"appdata": [m.name for m in data_mappen],
"dumps": [dp.name for dp in dumps],
# Leeg is het normale geval. Staat hier iets in, dan is dit archief
# niet compleet en moet je dat kunnen zien vóór je het terugzet.
"skipped": overgeslagen}
_schrijf_meta(doel, meta)
if log_fn:
if overgeslagen:
log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])}) — "
f"{len(overgeslagen)} bestand(en) ontbreken")
else:
log_fn(f"Backup klaar: {naam} ({_leesbaar(meta['size'])})")
if cfg.load().get("BACKUP_VERIFY", True):
if log_fn:
log_fn("Archief controleren…")
ok, waarom = verify(naam)
meta = _meta_voor(doel)
if not ok:
# Het archief blijft staan: je wil kunnen zien wát er mis is, en een
# kapot archief weggooien terwijl het misschien deels leesbaar is
# helpt niemand.
raise BackupError(f"Backup gemaakt maar niet te lezen: {waarom}")
if log_fn:
log_fn("Archief is leesbaar en compleet.")
doel_extern = offsite_dir()
if doel_extern:
ok, waarom = kopieer_extern(naam, log_fn=log_fn)
meta = _meta_voor(doel)
if not ok and log_fn:
log_fn(f"Externe kopie mislukt: {waarom}")
return meta
def _schrijf_meta(archief: Path, meta: dict):
archief.with_suffix("").with_suffix(META_SUFFIX).write_text(
json.dumps(meta, indent=2), encoding="utf-8")
def _werk_meta_bij(archief: Path, **velden):
"""Voeg velden toe aan de metadata naast een archief."""
mf = archief.with_suffix("").with_suffix(META_SUFFIX)
meta = {}
if mf.exists():
try:
meta = json.loads(mf.read_text(encoding="utf-8"))
except Exception:
meta = {}
meta.update(velden)
try:
_schrijf_meta(archief, meta)
except OSError:
pass
return meta
def _leesbaar(bytes_: int) -> str:
eenheid = ["B", "KB", "MB", "GB"]
n = float(bytes_)
for e in eenheid:
if n < 1024 or e == eenheid[-1]:
return f"{n:.0f} {e}" if e == "B" else f"{n:.1f} {e}"
n /= 1024
return f"{n:.1f} GB"
# ── Tonen ────────────────────────────────────────────────────────────────────
def _meta_voor(archief: Path) -> dict:
mf = archief.with_suffix("").with_suffix(META_SUFFIX)
meta = {}
if mf.exists():
try:
meta = json.loads(mf.read_text(encoding="utf-8"))
except Exception:
meta = {}
st = archief.stat()
# Ontbreekt de metadata (backup van vóór deze versie), leid af uit de naam.
meta.setdefault("file", archief.name)
meta.setdefault("stack", archief.name.rsplit("_", 2)[0])
meta.setdefault("ts", st.st_mtime)
meta.setdefault("reason", "")
meta["size"] = st.st_size
meta["size_human"] = _leesbaar(st.st_size)
return meta
def list_all() -> list[dict]:
bd = backup_dir()
if not bd.is_dir():
return []
uit = []
for p in bd.iterdir():
if p.is_file() and p.name.endswith(SUFFIX):
try:
uit.append(_meta_voor(p))
except Exception:
continue
return sorted(uit, key=lambda m: m["ts"], reverse=True)
def list_for(stack: str) -> list[dict]:
return [m for m in list_all() if m.get("stack") == stack]
# ── Verifiëren ───────────────────────────────────────────────────────────────
def verify(bestandsnaam: str, diep: bool = False, log_fn=None) -> tuple[bool, str]:
"""Controleer of een archief werkelijk te lezen en terug te zetten is.
Gewoon: elk bestand in het archief helemaal uitlezen. Dat controleert de
tar-structuur én de gzip-checksum, want die zit aan het eind — een archief
dat halverwege is afgebroken valt hier door de mand terwijl het in een
directorylisting compleet lijkt.
Diep: daarnaast echt uitpakken naar een tijdelijke map, met dezelfde
beperkingen als een echt herstel. Dat is de enige manier om te weten dat
terugzetten werkt vóórdat je het nodig hebt.
"""
archief = _archive_path(bestandsnaam)
if not archief.is_file():
return False, "Backup niet gevonden."
aantal, bytes_gelezen = 0, 0
try:
with tarfile.open(archief, "r:gz") as tar:
for lid in tar:
if not lid.isfile():
continue
bron = tar.extractfile(lid)
if bron is None:
continue
while blok := bron.read(1024 * 256):
bytes_gelezen += len(blok)
aantal += 1
except Exception as e:
_werk_meta_bij(archief, verified_ok=False, verified_ts=time.time(),
verified_msg=str(e)[:200])
return False, f"Archief is beschadigd: {e}"
if aantal == 0:
_werk_meta_bij(archief, verified_ok=False, verified_ts=time.time(),
verified_msg="leeg archief")
return False, "Het archief bevat geen bestanden."
if diep:
ok, waarom = _proefherstel(archief, log_fn=log_fn)
_werk_meta_bij(archief, verified_ok=ok, verified_ts=time.time(),
verified_deep=True, verified_msg="" if ok else waarom[:200])
if not ok:
return False, waarom
return True, (f"{aantal} bestanden ({_leesbaar(bytes_gelezen)}) gelezen "
f"en proefgewijs uitgepakt.")
_werk_meta_bij(archief, verified_ok=True, verified_ts=time.time(),
verified_deep=False, verified_msg="")
return True, f"{aantal} bestanden ({_leesbaar(bytes_gelezen)}) gelezen, archief is heel."
def _proefherstel(archief: Path, log_fn=None) -> tuple[bool, str]:
"""Pak het archief uit in een tijdelijke map en ruim die weer op."""
u = diskspace.usage(archief.parent)
if u.get("ok") and archief.stat().st_size * 4 > u["free"]:
return False, ("Te weinig vrije ruimte voor een proefherstel "
f"({diskspace.leesbaar(u['free'])} vrij).")
if log_fn:
log_fn("Proefherstel naar een tijdelijke map…")
tijdelijk = tempfile.mkdtemp(prefix="su-verify-", dir=str(archief.parent))
try:
with tarfile.open(archief, "r:gz") as tar:
tar.extractall(tijdelijk, filter="data")
mappen = [p for p in Path(tijdelijk).iterdir() if p.is_dir()]
if not mappen:
return False, "Het archief bevat geen stackmap."
if not any(p.is_file() for p in mappen[0].rglob("*")):
return False, f"De map '{mappen[0].name}' in het archief is leeg."
return True, ""
except Exception as e:
return False, f"Proefherstel mislukt: {e}"
finally:
shutil.rmtree(tijdelijk, ignore_errors=True)
# ── Externe kopie ────────────────────────────────────────────────────────────
def offsite_dir() -> Path | None:
"""De tweede bestemming, of None als die niet ingesteld of onbruikbaar is."""
pad = (cfg.load().get("BACKUP_OFFSITE_DIR") or "").strip()
if not pad:
return None
d = Path(pad).resolve()
if d == backup_dir():
return None # zelfde map is geen tweede kopie
return d
def kopieer_extern(bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
"""Kopieer archief en metadata naar de externe bestemming.
Bewust een gewone mapkopie: dat werkt met een gemounte USB-schijf, een
NFS- of SMB-share en een netwerkdrive, zonder extra dependency of een
wachtwoord dat wij moeten bewaren.
"""
doelmap = offsite_dir()
if doelmap is None:
return False, "Geen externe bestemming ingesteld."
archief = _archive_path(bestandsnaam)
if not archief.is_file():
return False, "Backup niet gevonden."
try:
doelmap.mkdir(parents=True, exist_ok=True)
except OSError as e:
return False, f"Bestemming niet bruikbaar: {e}"
# Is de mount weggevallen, dan bestaat het pad vaak nog als lege map op de
# lokale schijf — en vult de kopie ongemerkt je systeemschijf.
u = diskspace.usage(doelmap)
if u.get("ok") and archief.stat().st_size > u["free"]:
return False, (f"Te weinig ruimte op de bestemming: "
f"{diskspace.leesbaar(u['free'])} vrij, archief is "
f"{diskspace.leesbaar(archief.stat().st_size)}.")
if log_fn:
log_fn(f"Kopiëren naar {doelmap}")
try:
# Eerst onder een tijdelijke naam, dan hernoemen: een afgebroken kopie
# blijft zo herkenbaar onaf in plaats van een half archief dat er goed
# uitziet.
tijdelijk = doelmap / (archief.name + ".part")
shutil.copy2(archief, tijdelijk)
tijdelijk.replace(doelmap / archief.name)
mf = archief.with_suffix("").with_suffix(META_SUFFIX)
if mf.exists():
shutil.copy2(mf, doelmap / mf.name)
except OSError as e:
return False, f"Kopiëren mislukt: {e}"
_werk_meta_bij(archief, offsite_ok=True, offsite_ts=time.time(),
offsite_path=str(doelmap / archief.name))
if log_fn:
log_fn("Externe kopie klaar.")
return True, f"Gekopieerd naar {doelmap}."
# ── Terugzetten ──────────────────────────────────────────────────────────────
def restore(stack: str, bestandsnaam: str, log_fn=None) -> tuple[bool, str]:
"""Zet een backup terug: stack stoppen, uitpakken, weer starten.
De huidige map wordt eerst opzijgezet. Mislukt het uitpakken, dan komt de
oude situatie terug — je raakt dus nooit alles kwijt door een kapot archief.
"""
archief = _archive_path(bestandsnaam)
if not archief.is_file():
return False, "Backup niet gevonden."
doel = _stack_dir(stack)
meta = _meta_voor(archief)
if meta.get("stack") and meta["stack"] != stack:
return False, (f"Deze backup hoort bij '{meta['stack']}', niet bij "
f"'{stack}'.")
if doel.is_dir() and docker.has_compose(doel):
if log_fn:
log_fn("Stack stoppen…")
docker.compose_down(doel, log_fn=log_fn, name=stack)
opzij = doel.with_name(f"{doel.name}.restore-{int(time.time())}")
verplaatst = False
if doel.exists():
doel.rename(opzij)
verplaatst = True
if log_fn:
log_fn(f"Huidige map opzijgezet als {opzij.name}")
try:
with tempfile.TemporaryDirectory() as t:
tmp = Path(t)
with tarfile.open(archief, "r:gz") as tar:
# `filter="data"` weigert absolute paden, '..' en
# apparaatbestanden — maar laat ook uid en gid vallen (PEP 706).
# Die lezen we daarom apart uit, want alleen het archief weet
# van wie de appdata was.
eigenaars = {m.name: (m.uid, m.gid) for m in tar.getmembers()
if m.name.startswith("appdata/")}
tar.extractall(tmp, filter="data")
if not (tmp / stack).is_dir():
raise BackupError(
"Het archief bevatte geen map met de naam van de stack.")
shutil.move(str(tmp / stack), str(doel))
# De appdata gaat terug naar waar hij vandaan kwam; dat pad staat in
# de metadata die we net hebben teruggezet.
_zet_appdata_terug(stack, tmp / "appdata", eigenaars, log_fn=log_fn)
dumps = sorted((tmp / "dumps").glob("*")) if (tmp / "dumps").is_dir() else []
for dp in dumps:
shutil.copy2(dp, doel / f".restore-{dp.name}")
except Exception as e:
# Terug naar de oude situatie.
if doel.exists():
shutil.rmtree(doel, ignore_errors=True)
if verplaatst:
opzij.rename(doel)
if log_fn:
log_fn("Oude situatie teruggezet.")
return False, f"Terugzetten mislukt: {e}"
if verplaatst:
shutil.rmtree(opzij, ignore_errors=True)
if log_fn:
log_fn("Bestanden teruggezet — stack starten…")
rc = docker.compose_up(doel, log_fn=log_fn, name=stack)
if rc != 0:
return False, ("Bestanden zijn teruggezet, maar de stack startte niet. "
"Bekijk de logs.")
# Pas nu de dumps: een dump inspelen vóór de database draait doet niets.
fouten = _speel_dumps_in(stack, doel, log_fn=log_fn)
if fouten:
return False, ("De stack draait, maar het terugzetten van de database "
"mislukte: " + "; ".join(fouten))
return True, f"'{stack}' teruggezet uit {bestandsnaam}."
def _verwachte_eigenaar(waarden: dict) -> tuple[int, int]:
"""Van wie hoort de teruggezette appdata te zijn?
`tarfile` met `filter="data"` (PEP 706) laat uid en gid uit het archief
vallen — alles wordt van het proces dat uitpakt. Draait Server Up onder een
eigen account, dan krijgt de app zijn gegevens terug als een gebruiker die
hij niet is, en start hij niet meer. De PUID/PGID uit de metadata is wat de
app verwacht; staat die er niet, dan is onze eigen uid het beste antwoord.
"""
def _getal(sleutel, terugval):
try:
return int(str(waarden.get(sleutel)).strip())
except (TypeError, ValueError):
return terugval
return _getal("puid", os.getuid()), _getal("pgid", os.getgid())
def _zet_eigenaar(wortel: Path, uid: int, gid: int, log_fn=None) -> bool:
"""Zet map en inhoud op deze eigenaar. False als dat niet mocht."""
try:
os.chown(wortel, uid, gid, follow_symlinks=False)
for p in wortel.rglob("*"):
os.chown(p, uid, gid, follow_symlinks=False)
return True
except (OSError, NotImplementedError) as e:
if log_fn:
log_fn(f"Let op: eigenaar van {wortel.name} kon niet op {uid}:{gid} "
f"gezet worden ({e}). De app kan mogelijk niet bij zijn "
f"gegevens.")
return False
def _zet_appdata_terug(stack: str, bron: Path, eigenaars: dict | None = None,
log_fn=None):
"""De appdata-tak uit het archief terug naar de appdata-map.
`eigenaars` is `{arcnaam: (uid, gid)}` zoals het in het archief stond. Dat
is het enige dat écht klopt: een postgres-container draait als uid 70 en
zegt dat nergens in de metadata. Ontbreekt het, dan is `PUID`/`PGID` uit de
stack het volgende antwoord, en pas daarna onze eigen uid.
"""
if not bron.is_dir():
return
eigenaars = eigenaars or {}
waarden = (_stack_meta(stack).get("values") or {})
terugval = _verwachte_eigenaar(waarden)
basis = Path(str(waarden.get("appdata_dir")
or cfg.load().get("DATA_DIR") or "")).resolve()
if basis == Path("/") or not basis.parent.exists():
if log_fn:
log_fn("Appdata overgeslagen: geen bruikbaar doelpad in de metadata.")
return
basis.mkdir(parents=True, exist_ok=True)
for m in sorted(bron.iterdir()):
if not m.is_dir() or "/" in m.name or m.name in ("", ".", ".."):
continue
doel = basis / m.name
if doel.exists():
shutil.rmtree(doel, ignore_errors=True)
shutil.move(str(m), str(doel))
n = _herstel_eigenaars(doel, f"appdata/{m.name}", eigenaars, terugval,
log_fn=log_fn)
if log_fn:
log_fn(f"Appdata teruggezet: {m.name} ({n} keer eigenaar gezet)")
def _herstel_eigenaars(wortel: Path, arcnaam: str, eigenaars: dict,
terugval: tuple[int, int], log_fn=None) -> int:
"""Geef alles onder `wortel` de eigenaar die in het archief stond."""
gedaan = 0
gemeld = []
for pad, naam in [(wortel, arcnaam)] + [
(p, f"{arcnaam}/{p.relative_to(wortel)}") for p in wortel.rglob("*")]:
uid, gid = eigenaars.get(naam, terugval)
try:
os.chown(pad, uid, gid, follow_symlinks=False)
gedaan += 1
except (OSError, NotImplementedError) as e:
if not gemeld and log_fn:
gemeld.append(1)
log_fn(f"Let op: eigenaar van {wortel.name} kon niet gezet "
f"worden ({e}). De app kan mogelijk niet bij zijn gegevens.")
return gedaan
def _speel_dumps_in(stack: str, stackmap: Path, log_fn=None) -> list[str]:
"""De meegeleverde dumps in de draaiende databases zetten.
Retourneert de fouten; een lege lijst betekent dat alles gelukt is, óók als
er geen dumps in het archief zaten.
"""
dumps = sorted(stackmap.glob(".restore-*"))
if not dumps:
return []
diensten = {d["service"]: d for d in databasediensten(stack)}
fouten = []
for dp in dumps:
naam = dp.name[len(".restore-"):]
service = naam.rsplit(".", 1)[0]
dienst = diensten.get(service)
if not dienst:
fouten.append(f"{service}: hoort niet bij deze stack")
dp.unlink(missing_ok=True)
continue
if not _wacht_op_database(dienst, log_fn=log_fn):
fouten.append(f"{dienst['container']}: reageerde niet op tijd")
continue
cmd = _herstel_commando(dienst)
r = docker._run([docker._bin(), "exec", "-i", dienst["container"]] + cmd,
input=dp.read_bytes(), capture_output=True,
text=False, timeout=900)
if r.returncode != 0:
rauw = (r.stderr or b"").decode("utf-8", "replace")
fouten.append(f"{dienst['container']}: {rauw.strip().splitlines()[-1:] or ['fout']}")
elif log_fn:
log_fn(f"Database teruggezet in {dienst['container']}")
dp.unlink(missing_ok=True)
return fouten
def _herstel_commando(dienst: dict) -> list[str]:
s, gb, db, ww = (dienst["soort"], dienst["gebruiker"],
dienst["database"], dienst["wachtwoord"])
if s in ("postgres", "postgis"):
return ["sh", "-c",
f"PGPASSWORD='{ww}' psql -U '{gb or 'postgres'}' "
f"-d '{db or gb or 'postgres'}'"]
if s in ("mariadb", "mysql"):
hulp = "mariadb" if s == "mariadb" else "mysql"
return ["sh", "-c", f"{hulp} -u '{gb or 'root'}' -p'{ww}' '{db or gb}'"]
return ["sh", "-c",
f"mongorestore --archive --drop --username='{gb or 'app'}' "
f"--password='{ww}' --authenticationDatabase=admin"]
def _wacht_op_database(dienst: dict, pogingen: int = 30, log_fn=None) -> bool:
"""Een database heeft na het starten even nodig voor hij verbindingen aanneemt."""
controle = {
"postgres": ["pg_isready", "-U", dienst["gebruiker"] or "postgres"],
"postgis": ["pg_isready", "-U", dienst["gebruiker"] or "postgres"],
"mariadb": ["sh", "-c", f"mariadb-admin ping -p'{dienst['wachtwoord']}'"],
"mysql": ["sh", "-c", f"mysqladmin ping -p'{dienst['wachtwoord']}'"],
"mongo": ["sh", "-c", "mongosh --quiet --eval 'db.runCommand(1)'"],
}[dienst["soort"]]
for poging in range(pogingen):
r = docker._run([docker._bin(), "exec", dienst["container"]] + controle,
timeout=15)
if r.returncode == 0:
return True
if poging == 0 and log_fn:
log_fn(f"Wachten tot {dienst['container']} verbindingen aanneemt…")
time.sleep(2)
return False
# ── Opruimen ─────────────────────────────────────────────────────────────────
def delete(bestandsnaam: str) -> tuple[bool, str]:
archief = _archive_path(bestandsnaam)
if not archief.is_file():
return False, "Backup niet gevonden."
archief.unlink()
archief.with_suffix("").with_suffix(META_SUFFIX).unlink(missing_ok=True)
return True, "Backup verwijderd."
def prune(stack: str | None = None, log_fn=None) -> list[str]:
"""Pas het retentiebeleid toe. Retourneert de verwijderde bestandsnamen.
Twee regels, die allebei mogen gelden: hoogstens BACKUP_KEEP backups per
stack, en niets ouder dan BACKUP_MAX_AGE_DAYS.
"""
c = cfg.load()
keep = int(c.get("BACKUP_KEEP") or 0)
max_age = int(c.get("BACKUP_MAX_AGE_DAYS") or 0)
if keep <= 0 and max_age <= 0:
return []
verwijderd: list[str] = []
per_stack: dict[str, list[dict]] = {}
for m in list_all():
if stack and m.get("stack") != stack:
continue
per_stack.setdefault(m.get("stack", "?"), []).append(m)
grens = time.time() - max_age * 86400 if max_age > 0 else None
for naam, lijst in per_stack.items():
lijst.sort(key=lambda m: m["ts"], reverse=True) # nieuwste eerst
for i, m in enumerate(lijst):
te_veel = keep > 0 and i >= keep
te_oud = grens is not None and m["ts"] < grens
# De nieuwste backup van een stack blijft altijd staan, ook als hij
# te oud is — anders houd je na een vakantie niets over.
if (te_veel or te_oud) and i > 0:
try:
delete(m["file"])
verwijderd.append(m["file"])
if log_fn:
log_fn(f"Opgeruimd: {m['file']}")
except Exception:
continue
return verwijderd