All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
Het onbewaakte deel was de zwakke plek: geplande taken draaiden 's nachts en een storing kwam alleen in het auditlog terecht. Meldingen (core/notify.py): - ntfy, webhook (Discord/Slack/Gotify) en e-mail, alle drie met de standaardbibliotheek. - Gebeurtenissen: mislukte backup, onleesbaar archief, vastgelopen taak, weinig schijfruimte, beschikbare update, gestopte container. - Eén bericht per ronde; bij schijfruimte en gestopte containers alleen bij de overgang, zodat je niet elk uur hetzelfde krijgt. - Testknop die eerst opslaat, zodat je test wat je net hebt ingevuld. - send() gooit nooit: het kanaal mag de taak die de melding veroorzaakte niet alsnog laten omvallen. Schijfruimte (core/diskspace.py): - Controle vóór elke backup; past het niet, dan weigeren in plaats van halverwege afbreken. - DISK_MIN_FREE_GB blijft gereserveerd, DISK_WARN_PCT kleurt de balk rood. - Per filesystem één regel in de backuplijst. Backups controleren: - Elk nieuw archief wordt helemaal uitgelezen (tar-structuur plus gzip-checksum, die aan het eind staat). - Diepe variant pakt echt uit naar een tijdelijke map, met dezelfde beperkingen als een echt herstel. - Resultaat staat in de metadata en als schildje in de lijst. Backups de deur uit: - Downloadknop. - BACKUP_OFFSITE_DIR kopieert elke nieuwe backup naar een gemounte schijf, NFS- of SMB-share, via .part zodat een afgebroken kopie herkenbaar onaf is. - Ruimtecontrole op de bestemming, want een weggevallen mount laat vaak een lege map op de systeemschijf achter. NOTIFY_TOKEN en NOTIFY_EMAIL_PASSWORD zijn write-only. Nieuw: docs/meldingen.md; docs/backups.md uitgebreid. Getest tegen een draaiende server met een echte ontvanger. 1064 tests groen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
287 lines
10 KiB
Python
287 lines
10 KiB
Python
"""Backups: verifiëren, downloaden, extern wegzetten en de ruimtecontrole.
|
|
|
|
Een backup die je nooit leest is een aanname, en een backup op dezelfde schijf
|
|
als de data overleeft geen defecte schijf. Dit bestand test wat die twee
|
|
aannames wegneemt.
|
|
"""
|
|
import gzip
|
|
import sys
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
|
|
|
from conftest import login
|
|
|
|
|
|
@pytest.fixture
|
|
def bk(env, monkeypatch):
|
|
from core import backups, docker
|
|
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
|
|
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"):
|
|
d = env["lib"] / naam
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
(d / "docker-compose.yml").write_text("services:\n app:\n image: nginx\n",
|
|
encoding="utf-8")
|
|
(d / ".env").write_text("PORT=8222\n", encoding="utf-8")
|
|
return d
|
|
|
|
|
|
# ── Verifiëren ───────────────────────────────────────────────────────────────
|
|
|
|
def test_nieuw_archief_is_meteen_geverifieerd(bk, env):
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
assert meta.get("verified_ok") is True
|
|
assert meta.get("verified_ts")
|
|
|
|
|
|
def test_verify_leest_het_hele_archief(bk, env):
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
ok, msg = bk.verify(meta["file"])
|
|
assert ok is True
|
|
assert "bestanden" in msg
|
|
|
|
|
|
def test_verify_ziet_een_afgekapt_archief(bk, env):
|
|
"""Een archief dat halverwege is afgebroken ziet er in een lijst compleet
|
|
uit; de gzip-checksum staat aan het eind en valt dan pas door de mand."""
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
pad = bk.backup_dir() / meta["file"]
|
|
ruw = pad.read_bytes()
|
|
pad.write_bytes(ruw[: len(ruw) // 2])
|
|
|
|
ok, msg = bk.verify(meta["file"])
|
|
assert ok is False
|
|
assert "beschadigd" in msg
|
|
assert bk.list_for("vaultwarden")[0]["verified_ok"] is False
|
|
|
|
|
|
def test_verify_ziet_omgewisselde_bytes(bk, env):
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
pad = bk.backup_dir() / meta["file"]
|
|
ruw = bytearray(pad.read_bytes())
|
|
ruw[len(ruw) // 2] ^= 0xFF
|
|
pad.write_bytes(bytes(ruw))
|
|
assert bk.verify(meta["file"])[0] is False
|
|
|
|
|
|
def test_verify_weigert_een_leeg_archief(bk, env):
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
pad = bk.backup_dir() / meta["file"]
|
|
with tarfile.open(pad, "w:gz"):
|
|
pass # geldig, maar zonder inhoud
|
|
ok, msg = bk.verify(meta["file"])
|
|
assert ok is False
|
|
assert "geen bestanden" in msg
|
|
|
|
|
|
def test_verify_van_onbekend_bestand(bk, env):
|
|
ok, msg = bk.verify("bestaatniet_20260101_000000.tar.gz")
|
|
assert ok is False
|
|
assert "niet gevonden" in msg
|
|
|
|
|
|
@pytest.mark.parametrize("naam", ["../../etc/passwd", "/etc/passwd", "geen-backup"])
|
|
def test_verify_weigert_padtrucs(bk, naam):
|
|
with pytest.raises(bk.BackupError):
|
|
bk.verify(naam)
|
|
|
|
|
|
def test_proefherstel_pakt_echt_uit_en_ruimt_op(bk, env):
|
|
"""Alleen zo weet je dat terugzetten werkt vóórdat je het nodig hebt."""
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
voor = set(p.name for p in bk.backup_dir().iterdir())
|
|
|
|
ok, msg = bk.verify(meta["file"], diep=True)
|
|
assert ok is True, msg
|
|
assert "uitgepakt" in msg
|
|
assert bk.list_for("vaultwarden")[0]["verified_deep"] is True
|
|
# Geen rommel achtergelaten.
|
|
assert set(p.name for p in bk.backup_dir().iterdir()) == voor
|
|
|
|
|
|
def test_create_faalt_zichtbaar_bij_een_kapot_archief(bk, env, monkeypatch):
|
|
"""Je wil kunnen zien wát er mis is, dus het archief blijft staan."""
|
|
_maak_stack(env)
|
|
monkeypatch.setattr(bk, "verify", lambda *a, **k: (False, "stuk"))
|
|
with pytest.raises(bk.BackupError) as exc:
|
|
bk.create("vaultwarden")
|
|
assert "niet te lezen" in str(exc.value)
|
|
# Het archief staat er nog, zodat je kunt kijken wat er mis is.
|
|
assert list(bk.backup_dir().glob("*.tar.gz"))
|
|
|
|
|
|
def test_verificatie_uitzetten_kan(bk, env):
|
|
env["core"].patch({"BACKUP_VERIFY": False})
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
assert "verified_ok" not in meta
|
|
|
|
|
|
# ── Schijfruimte ─────────────────────────────────────────────────────────────
|
|
|
|
def test_backup_wordt_geweigerd_bij_te_weinig_ruimte(bk, env, monkeypatch):
|
|
from core import diskspace
|
|
_maak_stack(env)
|
|
monkeypatch.setattr(diskspace.shutil, "disk_usage", lambda p: (1000, 999, 1))
|
|
with pytest.raises(bk.BackupError) as exc:
|
|
bk.create("vaultwarden")
|
|
assert "schijfruimte" in str(exc.value).lower()
|
|
# En er is geen half archief blijven staan.
|
|
assert not list(bk.backup_dir().glob("*.tar.gz"))
|
|
|
|
|
|
# ── Externe kopie ────────────────────────────────────────────────────────────
|
|
|
|
def test_kopieert_naar_de_externe_bestemming(bk, env):
|
|
extern = env["tmp"] / "extern"
|
|
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
|
|
assert (extern / meta["file"]).is_file()
|
|
assert (extern / meta["file"]).stat().st_size == (bk.backup_dir() / meta["file"]).stat().st_size
|
|
# De metadata gaat mee, anders weet je bij de kopie niet waar hij bij hoort.
|
|
assert list(extern.glob("*.json"))
|
|
assert bk.list_for("vaultwarden")[0]["offsite_ok"] is True
|
|
|
|
|
|
def test_geen_half_bestand_bij_een_afgebroken_kopie(bk, env, monkeypatch):
|
|
"""Een .part blijft herkenbaar onaf staan in plaats van een archief dat er
|
|
compleet uitziet."""
|
|
import shutil as sh
|
|
extern = env["tmp"] / "extern"
|
|
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
|
|
monkeypatch.setattr(bk.shutil, "copy2",
|
|
lambda *a, **k: (_ for _ in ()).throw(OSError("mount weg")))
|
|
ok, msg = bk.kopieer_extern(meta["file"])
|
|
assert ok is False
|
|
assert "mislukt" in msg
|
|
|
|
|
|
def test_zelfde_map_is_geen_tweede_kopie(bk, env):
|
|
env["core"].patch({"BACKUP_OFFSITE_DIR": str(bk.backup_dir())})
|
|
assert bk.offsite_dir() is None
|
|
|
|
|
|
def test_offsite_weigert_bij_te_weinig_ruimte_op_de_bestemming(bk, env, monkeypatch):
|
|
"""Valt de mount weg, dan bestaat het pad vaak nog als lege map op de
|
|
systeemschijf — en vult de kopie ongemerkt de verkeerde schijf."""
|
|
from core import diskspace
|
|
extern = env["tmp"] / "extern"
|
|
_maak_stack(env)
|
|
meta = bk.create("vaultwarden")
|
|
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
|
|
monkeypatch.setattr(diskspace.shutil, "disk_usage", lambda p: (1000, 999, 1))
|
|
ok, msg = bk.kopieer_extern(meta["file"])
|
|
assert ok is False
|
|
assert "ruimte" in msg
|
|
|
|
|
|
def test_mislukte_externe_kopie_laat_de_backup_zelf_staan(bk, env, monkeypatch):
|
|
"""De lokale backup is het belangrijkste; de kopie is een extra."""
|
|
extern = env["tmp"] / "extern"
|
|
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
|
|
_maak_stack(env)
|
|
monkeypatch.setattr(bk, "kopieer_extern", lambda *a, **k: (False, "mount weg"))
|
|
meta = bk.create("vaultwarden")
|
|
assert (bk.backup_dir() / meta["file"]).is_file()
|
|
|
|
|
|
# ── API ──────────────────────────────────────────────────────────────────────
|
|
|
|
def test_downloaden(client, env, monkeypatch):
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
|
|
login(client)
|
|
_maak_stack(env)
|
|
meta = backups.create("vaultwarden")
|
|
|
|
r = client.get(f"/api/backups/{meta['file']}/download")
|
|
assert r.status_code == 200
|
|
assert r.headers["Content-Disposition"].startswith("attachment")
|
|
assert meta["file"] in r.headers["Content-Disposition"]
|
|
# En het is een echt, uitpakbaar archief.
|
|
assert gzip.decompress(r.data)[:100]
|
|
|
|
|
|
@pytest.mark.parametrize("naam", ["../config.json", "..%2Fconfig.json", "onzin"])
|
|
def test_download_weigert_padtrucs(client, env, naam):
|
|
login(client)
|
|
r = client.get(f"/api/backups/{naam}/download")
|
|
assert r.status_code in (400, 404), r.data
|
|
|
|
|
|
def test_download_van_onbekend_bestand(client, env):
|
|
login(client)
|
|
r = client.get("/api/backups/weg_20260101_000000.tar.gz/download")
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_download_vereist_een_sessie(anon_client):
|
|
r = anon_client.get("/api/backups/x_20260101_000000.tar.gz/download")
|
|
assert r.status_code == 401
|
|
|
|
|
|
def test_offsite_endpoint(client, env, monkeypatch):
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
|
|
csrf = login(client)
|
|
_maak_stack(env)
|
|
meta = backups.create("vaultwarden")
|
|
|
|
extern = env["tmp"] / "extern"
|
|
client.put("/api/settings", json={"BACKUP_OFFSITE_DIR": str(extern)},
|
|
headers={"X-CSRF-Token": csrf})
|
|
r = client.post(f"/api/backups/{meta['file']}/offsite",
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200, r.get_json()
|
|
assert (extern / meta["file"]).is_file()
|
|
|
|
|
|
def test_offsite_zonder_bestemming_zegt_dat(client, env, monkeypatch):
|
|
from core import backups, docker
|
|
monkeypatch.setattr(docker, "compose_down", lambda *a, **k: 0)
|
|
monkeypatch.setattr(docker, "compose_up", lambda *a, **k: 0)
|
|
env["core"].patch({"BACKUP_DIR": str(env["tmp"] / "backups")})
|
|
csrf = login(client)
|
|
_maak_stack(env)
|
|
meta = backups.create("vaultwarden")
|
|
r = client.post(f"/api/backups/{meta['file']}/offsite",
|
|
headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
assert "bestemming" in r.get_json()["msg"]
|
|
|
|
|
|
def test_backuplijst_toont_de_schijfruimte(client, env):
|
|
login(client)
|
|
d = client.get("/api/backups").get_json()
|
|
assert d["disk"]["volumes"]
|
|
assert "BACKUP_OFFSITE_DIR" in d["settings"]
|
|
|
|
|
|
def test_disk_endpoint(client, env):
|
|
login(client)
|
|
d = client.get("/api/disk").get_json()
|
|
assert d["volumes"][0]["free"] > 0
|
|
assert "low" in d
|