server-up/tests/test_diskspace.py
Ramon 87fb9d19d1
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m22s
v0.7.50-beta - meldingen, schijfruimte, backups controleren en extern wegzetten
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
2026-07-28 19:41:35 +02:00

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