Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 6s
Backups (core/backups.py, core/scheduler.py): - Terugzetten met een klik: stack stoppen, huidige map opzij, uitpakken, starten. Mislukt het uitpakken, dan wordt de oude situatie teruggeplaatst. - Bewaarbeleid: aantal per stack en/of maximale leeftijd; de nieuwste backup van een stack blijft altijd staan. - Geplande backups (dagelijks/wekelijks) via een eigen planner in de app, geen cron. Een gemiste ronde loopt bij de eerstvolgende gelegenheid alsnog. - Automatisch een backup voor het bijwerken of verwijderen van een stack. Mislukt die, dan gaat de actie door - anders kun je een kapotte stack niet meer opruimen. - Uitpakken met tarfile + filter="data": absolute paden en ..-ingangen worden geweigerd. Met een kaal `tar xzf` kon een geprepareerd archief buiten de stackmap schrijven. - Twee bugs in de oude implementatie: de returncode van tar werd genegeerd (mislukte backup gold als succes) en backups binnen dezelfde seconde overschreven elkaar. Updates per app (core/stackupdates.py, core/registry.py): - Badge op de stackkaart als er een nieuwer image is; bijwerken doet de bestaande update-knop. - Vergelijking via de Registry API v2 (Docker-Content-Digest) in plaats van `docker manifest inspect`: dat laatste geeft per platform een aparte digest terwijl RepoDigests de manifest-list-digest bevat, wat bij elk multi-arch image permanent "update beschikbaar" zou opleveren. - Drie statussen: update / current / unknown. Lokaal gebouwd, nog niet gepulld of registry onbereikbaar geeft unknown en dus geen badge. - Dagelijkse achtergrondcheck, resultaten 6 uur gecached. Verder: - docs/backups.md, met nadruk op wat er niet in een backup zit: de Docker-volumes met de eigenlijke appdata. - Backup-instellingen onder Instellingen; backup-geschiedenis per stack. - 197 tests groen (40 nieuwe). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
186 lines
5.9 KiB
Python
186 lines
5.9 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
|