All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 16m26s
- 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
871 lines
35 KiB
Python
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
|