All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m31s
- Een backup bevatte alleen de stackmap: compose, .env en metadata. LIBRARY_DIR en de appdata-map staan naast elkaar, geen genestelde mappen, dus je maakte een backup, kreeg een groen vinkje en hield bij terugzetten een lege app over. Een archief heeft nu drie takken: de stackmap, de appdata van die stack, en een dump per database - De dump is geen dubbelop: een tar van een draaiende PostgreSQL is een momentopname van bestanden die tijdens het inpakken veranderden. Bij terugzetten komt de dump erin nadat de container draait, met een wachtlus. Stond de container tijdens de backup uit, dan is dat een waarschuwing in het log en geen stille overslag - Nieuwe instelling BACKUP_APPDATA (standaard aan), uit te zetten bij een mediabibliotheek waar dit in de honderden gigabytes loopt - apps/newt en apps/pangolin. Server Up had al een Pangolin-integratie met een publiceerknop per stack, maar je moest Pangolin zelf elders vandaan halen - Nieuwe stap in de setup-wizard na git: koppelen aan een bestaande Pangolin, hier installeren, of overslaan. De wizard hing zijn afhandeling aan stapnummers, dus met een stap ertussen ging de git-stap mis; dat gaat nu op de naam van de stap - depends_on waarschuwt nu ook op de stackpagina. Haal je Mosquitto later weg, dan viel Zigbee2MQTT stil zonder dat iets zei waarom Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
318 lines
11 KiB
Python
318 lines
11 KiB
Python
"""Backups: maken, tonen, terugzetten, retentie — en de veiligheid daarvan."""
|
|
import io
|
|
import json
|
|
import tarfile
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from conftest import login
|
|
|
|
|
|
@pytest.fixture
|
|
def bk(env, monkeypatch):
|
|
from core import backups, docker
|
|
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
|
|
# Geen echte docker in de tests: stack stoppen/starten is een no-op.
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
return backups
|
|
|
|
|
|
def _maak_stack(env, naam="vaultwarden", inhoud="services:\n app:\n image: nginx\n"):
|
|
d = env["lib"] / naam
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "docker-compose.yml").write_text(inhoud, encoding="utf-8")
|
|
(d / ".env").write_text("PORT=8222\n", encoding="utf-8")
|
|
return d
|
|
|
|
|
|
def test_maken_en_tonen(bk, env):
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
assert meta["stack"] == "vaultwarden"
|
|
assert meta["size"] > 0
|
|
lijst = bk.list_for("vaultwarden")
|
|
assert len(lijst) == 1
|
|
assert lijst[0]["file"] == meta["file"]
|
|
assert lijst[0]["size_human"]
|
|
|
|
|
|
def test_maken_van_onbekende_stack(bk):
|
|
with pytest.raises(bk.BackupError):
|
|
bk.create("bestaatniet")
|
|
|
|
|
|
def test_terugzetten_herstelt_gewijzigd_bestand(bk, env):
|
|
d = _maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
(d / "docker-compose.yml").write_text("KAPOT", encoding="utf-8")
|
|
|
|
ok, msg = bk.restore("vaultwarden", meta["file"])
|
|
assert ok, msg
|
|
assert "image: nginx" in (d / "docker-compose.yml").read_text()
|
|
assert (d / ".env").read_text() == "PORT=8222\n"
|
|
|
|
|
|
def test_terugzetten_van_backup_van_andere_stack(bk, env):
|
|
_maak_stack(env, "app-a")
|
|
_maak_stack(env, "app-b")
|
|
meta = bk.create("app-a")
|
|
ok, msg = bk.restore("app-b", meta["file"])
|
|
assert ok is False
|
|
assert "hoort bij 'app-a'" in msg
|
|
|
|
|
|
@pytest.mark.parametrize("naam", [
|
|
"../../etc/passwd.tar.gz", "/etc/shadow.tar.gz", "geen-tijdstempel.tar.gz",
|
|
"vaultwarden_20260101_120000.txt", "", "..tar.gz",
|
|
])
|
|
def test_ongeldige_backupnaam(bk, naam):
|
|
with pytest.raises(bk.BackupError):
|
|
bk._archive_path(naam)
|
|
|
|
|
|
def test_terugzetten_weigert_archief_dat_buiten_de_map_schrijft(bk, env):
|
|
"""Een geprepareerd archief met '..' mag niets buiten de stackmap raken.
|
|
|
|
Met een kaal `tar xzf` zou dit bestand op de host belanden; tarfile met
|
|
filter='data' weigert de ingang.
|
|
"""
|
|
_maak_stack(env)
|
|
bd = Path(bk.backup_dir())
|
|
bd.mkdir(parents=True, exist_ok=True)
|
|
boos = bd / "vaultwarden_20260101_120000.tar.gz"
|
|
with tarfile.open(boos, "w:gz") as tar:
|
|
data = b"ontsnapt"
|
|
info = tarfile.TarInfo("../../ontsnapt.txt")
|
|
info.size = len(data)
|
|
tar.addfile(info, io.BytesIO(data))
|
|
|
|
ok, msg = bk.restore("vaultwarden", boos.name)
|
|
assert ok is False
|
|
assert not (env["tmp"] / "ontsnapt.txt").exists()
|
|
assert not (env["lib"].parent / "ontsnapt.txt").exists()
|
|
|
|
|
|
def test_mislukte_restore_zet_de_oude_map_terug(bk, env):
|
|
"""Een kapot archief mag je niet zonder stack achterlaten."""
|
|
d = _maak_stack(env)
|
|
bd = Path(bk.backup_dir())
|
|
bd.mkdir(parents=True, exist_ok=True)
|
|
stuk = bd / "vaultwarden_20260101_120000.tar.gz"
|
|
stuk.write_bytes(b"dit is geen gzip")
|
|
|
|
ok, _ = bk.restore("vaultwarden", stuk.name)
|
|
assert ok is False
|
|
assert d.is_dir()
|
|
assert "image: nginx" in (d / "docker-compose.yml").read_text()
|
|
|
|
|
|
def test_verwijderen(bk, env):
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
ok, _ = bk.delete(meta["file"])
|
|
assert ok
|
|
assert bk.list_for("vaultwarden") == []
|
|
|
|
|
|
def test_retentie_houdt_er_hoogstens_keep(bk, env):
|
|
_maak_stack(env)
|
|
env["core"].patch({"BACKUP_KEEP": 3})
|
|
metas = []
|
|
for i in range(5):
|
|
m = bk.create("vaultwarden")
|
|
# Tijdstempels uit elkaar trekken; de naam heeft secondeprecisie.
|
|
m["ts"] = time.time() - (5 - i)
|
|
_herschrijf_ts(bk, m)
|
|
metas.append(m)
|
|
bk.prune("vaultwarden")
|
|
assert len(bk.list_for("vaultwarden")) == 3
|
|
|
|
|
|
def test_retentie_bewaart_altijd_de_nieuwste(bk, env):
|
|
"""Ook als alles te oud is, blijft er één over."""
|
|
_maak_stack(env)
|
|
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 1})
|
|
for i in range(3):
|
|
m = bk.create("vaultwarden")
|
|
m["ts"] = time.time() - 30 * 86400
|
|
_herschrijf_ts(bk, m)
|
|
bk.prune("vaultwarden")
|
|
assert len(bk.list_for("vaultwarden")) == 1
|
|
|
|
|
|
def test_retentie_uit_doet_niets(bk, env):
|
|
_maak_stack(env)
|
|
env["core"].patch({"BACKUP_KEEP": 0, "BACKUP_MAX_AGE_DAYS": 0})
|
|
for _ in range(3):
|
|
bk.create("vaultwarden")
|
|
assert bk.prune("vaultwarden") == []
|
|
|
|
|
|
def _herschrijf_ts(bk, meta):
|
|
"""Zet de tijdstempel in het metadata-bestand terug in de tijd."""
|
|
p = Path(bk.backup_dir()) / meta["file"]
|
|
mf = p.with_suffix("").with_suffix(".json")
|
|
d = json.loads(mf.read_text())
|
|
d["ts"] = meta["ts"]
|
|
mf.write_text(json.dumps(d))
|
|
|
|
|
|
# ── API ──────────────────────────────────────────────────────────────────────
|
|
|
|
def test_endpoint_lijst(client, env, bk):
|
|
login(client)
|
|
_maak_stack(env)
|
|
bk.create("vaultwarden")
|
|
d = client.get("/api/backups").get_json()
|
|
assert len(d["backups"]) == 1
|
|
assert "BACKUP_KEEP" in d["settings"]
|
|
|
|
|
|
def test_endpoint_restore_weigert_ongeldige_naam(client, bk):
|
|
csrf = login(client)
|
|
r = client.post("/api/backups/vaultwarden/restore",
|
|
json={"file": "../../etc/passwd.tar.gz"},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_endpoint_restore_zonder_bestand(client, bk):
|
|
csrf = login(client)
|
|
r = client.post("/api/backups/vaultwarden/restore", json={},
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
|
|
|
|
# ── De appdata en de databases ───────────────────────────────────────────────
|
|
# Een backup bevatte alleen de stackmap. LIBRARY_DIR en de appdata zijn buren,
|
|
# geen genestelde mappen, dus je zette een backup terug en hield een lege app
|
|
# over — met een groen vinkje erbij.
|
|
|
|
def _stack_met_appdata(env, naam="umami", inhoud=b"gegevens"):
|
|
"""Een geïnstalleerde stack met een appdata-map ernaast."""
|
|
import json as _json
|
|
d = env["lib"] / naam
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "docker-compose.yml").write_text(
|
|
"services:\n"
|
|
f" {naam}:\n"
|
|
" image: nginx\n"
|
|
f" {naam}-db:\n"
|
|
" image: postgres:16-alpine\n"
|
|
f" container_name: {naam}-db\n"
|
|
" environment:\n"
|
|
" - POSTGRES_USER=umami\n"
|
|
" - POSTGRES_DB=umami\n"
|
|
" - POSTGRES_PASSWORD=${DB_PASSWORD}\n")
|
|
(d / ".env").write_text("DB_PASSWORD=geheim123\n")
|
|
appdata = Path(str(env["tmp"])) / "appdata"
|
|
(appdata / naam).mkdir(parents=True, exist_ok=True)
|
|
(appdata / f"{naam}-db").mkdir(parents=True, exist_ok=True)
|
|
(appdata / naam / "bestand.txt").write_bytes(inhoud)
|
|
(d / ".serverup.json").write_text(_json.dumps({
|
|
"source": naam,
|
|
"values": {"appdata_dir": str(appdata), "service_name": naam},
|
|
}))
|
|
return d, appdata
|
|
|
|
|
|
def test_appdata_gaat_mee_in_het_archief(env, monkeypatch):
|
|
import tarfile
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami")
|
|
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
assert "umami/docker-compose.yml" in namen
|
|
assert "appdata/umami/bestand.txt" in namen
|
|
assert "appdata/umami-db" in namen
|
|
assert meta["appdata"] == ["umami", "umami-db"]
|
|
|
|
|
|
def test_appdata_kan_uit(env, monkeypatch):
|
|
"""Bij een mediabibliotheek loopt dit in de honderden gigabytes."""
|
|
import tarfile
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami", appdata=False)
|
|
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
assert not [n for n in namen if n.startswith("appdata/")]
|
|
assert meta["appdata"] == []
|
|
|
|
|
|
def test_terugzetten_herstelt_de_appdata(env, monkeypatch):
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
_d, appdata = _stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami")
|
|
(appdata / "umami" / "bestand.txt").write_bytes(b"kapot")
|
|
|
|
ok, msg = backups.restore("umami", meta["file"])
|
|
assert ok, msg
|
|
assert (appdata / "umami" / "bestand.txt").read_bytes() == b"gegevens"
|
|
|
|
|
|
def test_databasedienst_wordt_herkend_met_wachtwoord_uit_env(env, monkeypatch):
|
|
"""Het wachtwoord staat als ${DB_PASSWORD} in compose en met de waarde in
|
|
.env — zonder die stap draait pg_dump met een lege sleutel."""
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt())
|
|
_stack_met_appdata(env)
|
|
|
|
diensten = backups.databasediensten("umami")
|
|
assert len(diensten) == 1
|
|
d = diensten[0]
|
|
assert d["container"] == "umami-db"
|
|
assert d["soort"] == "postgres"
|
|
assert d["gebruiker"] == "umami"
|
|
assert d["wachtwoord"] == "geheim123"
|
|
|
|
|
|
def test_dump_die_mislukt_wordt_gemeld_en_niet_stil_overgeslagen(env, monkeypatch):
|
|
"""Een archief dat compleet lijkt terwijl de database ontbreekt is het
|
|
ergste dat een backup kan doen."""
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run", lambda *a, **k: _mislukt(b"container niet gestart"))
|
|
_stack_met_appdata(env)
|
|
|
|
regels = []
|
|
meta = backups.create("umami", log_fn=regels.append)
|
|
assert meta["dumps"] == []
|
|
assert any("Geen dump van umami-db" in r for r in regels), regels
|
|
|
|
|
|
def test_dump_belandt_in_het_archief(env, monkeypatch):
|
|
import tarfile
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "_run",
|
|
lambda *a, **k: _gelukt(b"-- pg_dump\nCREATE TABLE x;\n"))
|
|
_stack_met_appdata(env)
|
|
|
|
meta = backups.create("umami")
|
|
with tarfile.open(backups.backup_dir() / meta["file"]) as tar:
|
|
namen = tar.getnames()
|
|
inhoud = tar.extractfile("dumps/umami-db.sql").read()
|
|
assert "dumps/umami-db.sql" in namen
|
|
assert b"CREATE TABLE x;" in inhoud
|
|
assert meta["dumps"] == ["umami-db.sql"]
|
|
|
|
|
|
def _mislukt(stderr=b"fout"):
|
|
import subprocess
|
|
return subprocess.CompletedProcess([], 1, b"", stderr)
|
|
|
|
|
|
def _gelukt(stdout=b""):
|
|
import subprocess
|
|
return subprocess.CompletedProcess([], 0, stdout, b"")
|