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
136 lines
4.8 KiB
Python
136 lines
4.8 KiB
Python
"""Schijfruimte: meten, drempels en het weigeren van een backup die niet past.
|
|
|
|
Een volle schijf op een Docker-host breekt alles, niet alleen Server Up. En een
|
|
backup die halverwege stopt omdat de ruimte op is, laat een archief achter dat
|
|
er in een lijst compleet uitziet.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up"))
|
|
|
|
|
|
@pytest.fixture
|
|
def ds(env):
|
|
from core import diskspace
|
|
return diskspace
|
|
|
|
|
|
def test_usage_geeft_bruikbare_getallen(ds, tmp_path):
|
|
u = ds.usage(tmp_path)
|
|
assert u["ok"] is True
|
|
assert u["total"] > 0
|
|
assert 0 <= u["pct_free"] <= 100
|
|
assert u["free_human"].endswith(("B", "KB", "MB", "GB", "TB"))
|
|
|
|
|
|
def test_usage_werkt_voor_een_map_die_nog_niet_bestaat(ds, tmp_path):
|
|
"""De backupmap wordt pas bij de eerste backup aangemaakt, maar we willen
|
|
de ruimte er vóór die tijd al van weten."""
|
|
u = ds.usage(tmp_path / "bestaat" / "nog" / "niet")
|
|
assert u["ok"] is True
|
|
assert u["total"] > 0
|
|
|
|
|
|
def test_onmeetbaar_pad_blokkeert_niets(ds, monkeypatch, tmp_path):
|
|
"""Kunnen we het niet meten, dan is tegenhouden erger dan doorlaten."""
|
|
def _stuk(_):
|
|
raise OSError("geen idee")
|
|
monkeypatch.setattr(ds.shutil, "disk_usage", _stuk)
|
|
assert ds.usage(tmp_path)["ok"] is False
|
|
ok, _ = ds.ruimte_voor_backup(tmp_path, tmp_path)
|
|
assert ok is True
|
|
|
|
|
|
@pytest.mark.parametrize("bytes_,verwacht", [
|
|
(0, "0 B"), (512, "512 B"), (1536, "1.5 KB"),
|
|
(1024 ** 2, "1.0 MB"), (3 * 1024 ** 3, "3.0 GB"),
|
|
])
|
|
def test_leesbaar(ds, bytes_, verwacht):
|
|
assert ds.leesbaar(bytes_) == verwacht
|
|
|
|
|
|
def test_mapgrootte_telt_alleen_bestanden(ds, tmp_path):
|
|
(tmp_path / "map").mkdir()
|
|
(tmp_path / "map" / "a.txt").write_bytes(b"x" * 100)
|
|
(tmp_path / "b.txt").write_bytes(b"y" * 50)
|
|
assert ds.mapgrootte(tmp_path) == 150
|
|
|
|
|
|
def test_mapgrootte_volgt_geen_symlinks(ds, tmp_path):
|
|
"""Een link naar /var zou de schatting onbruikbaar maken."""
|
|
groot = tmp_path / "groot.bin"
|
|
groot.write_bytes(b"z" * 1000)
|
|
doel = tmp_path / "map"
|
|
doel.mkdir()
|
|
(doel / "link").symlink_to(groot)
|
|
assert ds.mapgrootte(doel) == 0
|
|
|
|
|
|
def test_mapgrootte_stopt_bij_de_limiet(ds, tmp_path):
|
|
for i in range(10):
|
|
(tmp_path / f"{i}.bin").write_bytes(b"x" * 1000)
|
|
# Zodra de limiet gepasseerd is hoeft hij niet door te tellen.
|
|
assert ds.mapgrootte(tmp_path, limiet_bytes=2000) <= 10000
|
|
|
|
|
|
def test_ruimte_voor_backup_weigert_wat_niet_past(ds, env, monkeypatch, tmp_path):
|
|
bron = tmp_path / "stack"
|
|
bron.mkdir()
|
|
(bron / "data.bin").write_bytes(b"x" * 5000)
|
|
monkeypatch.setattr(ds.shutil, "disk_usage",
|
|
lambda p: (1000, 900, 100)) # 100 bytes vrij
|
|
ok, waarom = ds.ruimte_voor_backup(bron, tmp_path)
|
|
assert ok is False
|
|
assert "schijfruimte" in waarom.lower()
|
|
|
|
|
|
def test_ruimte_voor_backup_houdt_de_reserve_vrij(ds, env, monkeypatch, tmp_path):
|
|
"""Er is genoeg voor het archief zelf, maar niet voor archief + reserve."""
|
|
bron = tmp_path / "stack"
|
|
bron.mkdir()
|
|
(bron / "data.bin").write_bytes(b"x" * 1000)
|
|
env["core"].patch({"DISK_MIN_FREE_GB": 1})
|
|
monkeypatch.setattr(ds.shutil, "disk_usage",
|
|
lambda p: (10 * ds.GB, 9 * ds.GB, 5000))
|
|
ok, waarom = ds.ruimte_voor_backup(bron, tmp_path)
|
|
assert ok is False
|
|
assert "reserve" in waarom
|
|
|
|
|
|
def test_zonder_reserve_mag_het_krap(ds, env, monkeypatch, tmp_path):
|
|
bron = tmp_path / "stack"
|
|
bron.mkdir()
|
|
(bron / "data.bin").write_bytes(b"x" * 1000)
|
|
env["core"].patch({"DISK_MIN_FREE_GB": 0})
|
|
monkeypatch.setattr(ds.shutil, "disk_usage", lambda p: (10000, 8000, 2000))
|
|
assert ds.ruimte_voor_backup(bron, tmp_path)[0] is True
|
|
|
|
|
|
def test_drempel_markeert_krappe_schijf(ds, env, monkeypatch, tmp_path):
|
|
env["core"].patch({"DISK_MIN_FREE_GB": 0, "DISK_WARN_PCT": 20})
|
|
monkeypatch.setattr(ds.shutil, "disk_usage", lambda p: (1000, 900, 100))
|
|
assert ds.usage(tmp_path)["low"] is True
|
|
monkeypatch.setattr(ds.shutil, "disk_usage", lambda p: (1000, 100, 900))
|
|
assert ds.usage(tmp_path)["low"] is False
|
|
|
|
|
|
def test_status_toont_elk_filesystem_een_keer(ds, env):
|
|
"""Backups, stacks en appdata staan meestal op dezelfde schijf; drie
|
|
identieke regels helpen niemand."""
|
|
st = ds.status()
|
|
assert st["volumes"], st
|
|
sleutels = [(v["total"], v["free"]) for v in st["volumes"]]
|
|
assert len(sleutels) == len(set(sleutels))
|
|
|
|
|
|
def test_status_neemt_de_externe_bestemming_mee(ds, env, tmp_path):
|
|
extern = tmp_path / "extern"
|
|
extern.mkdir()
|
|
env["core"].patch({"BACKUP_OFFSITE_DIR": str(extern)})
|
|
labels = [v["label"] for v in ds.status()["volumes"]]
|
|
# Zelfde filesystem als de rest in de test, dus hij mag samenvallen — maar
|
|
# de instelling mag nooit een fout opleveren.
|
|
assert labels
|