server-up/tests/test_paden.py
Ramon 90e4160727
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 2m40s
v0.9.00-beta - één hoofdmap, paden kloppend en data verhuizen
- LIBRARY_DIR, DATA_DIR en BACKUP_DIR worden afgeleid van BASE_DIR in plaats
  van hard op /opt/serverup te staan
- docker-compose geeft BASE_DIR nu ook aan de container door; die stond alleen
  in de mount, dus de app bleef /opt/serverup voorstellen bij een /srv-mount
- het installatieformulier vult appdata_dir met de ingestelde DATA_DIR, zodat
  alle 286 sjablonen de juiste map voorstellen
- Instellingen → Paden heeft een hoofdmap met afgeleide submappen, een
  kopieerknop per pad en "Alles overnemen"
- nieuw GET /api/paths/check: is het pad schrijfbaar en binnen de gemounte
  BASE_DIR, met de .env-regel als dat niet zo is
- nieuw POST /api/paths/move: stacks stoppen, kopiëren, controleren, paden in
  compose/.env/metadata omschrijven, stacks starten en pas dán het oude
  opruimen
- jobs.voortgang() plus een voortgangsbalk boven het terminallog
- core/paden.py met meten, bereikbaarheid, verhuizen en omschrijven
- docs/installeren.md en .env.example beschrijven BASE_DIR als de manier om
  alles te verplaatsen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-08-02 14:06:17 +02:00

240 lines
9.9 KiB
Python

"""De mappen van Server Up verplaatsen.
Dit is het enige onderdeel dat gegevens van de gebruiker weggooit, dus de
volgorde is de hele zaak: kopiëren, controleren, en pas daarna opruimen. Een
verhuizing die halverwege strandt met het origineel al verwijderd is het ergste
dat hier kan gebeuren.
"""
import json
import os
import sys
from pathlib import Path
import pytest
WORTEL = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(WORTEL / "server-up"))
from conftest import login
def _vul(d: Path, bestanden: dict[str, str]) -> Path:
for naam, inhoud in bestanden.items():
p = d / naam
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(inhoud, encoding="utf-8")
return d
# ── Hoofdmap en afgeleide paden ──────────────────────────────────────────────
def test_afgeleide_paden(env):
import core
assert core.afgeleide_paden("/srv/serverup") == {
"LIBRARY_DIR": "/srv/serverup/stacks",
"DATA_DIR": "/srv/serverup/appdata",
"BACKUP_DIR": "/srv/serverup/backups",
}
# Een afsluitende schuine streep hoort niet te verdubbelen.
assert core.afgeleide_paden("/srv/serverup/")["DATA_DIR"] == "/srv/serverup/appdata"
# Een relatief pad levert niets op in plaats van iets kapots.
assert core.afgeleide_paden("srv/data") == {}
assert core.afgeleide_paden("") == {}
def test_base_dir_stuurt_de_standaarden(monkeypatch, tmp_path):
"""De drie paden stonden hard op /opt/serverup/*, ook als je BASE_DIR
verzette — precies waarom de interface /opt bleef voorstellen."""
import importlib
monkeypatch.setenv("BASE_DIR", "/srv/eigen")
monkeypatch.setenv("SU_CONFIG", str(tmp_path / "c.json"))
for naam in [n for n in sys.modules if n == "core" or n.startswith("core.")]:
del sys.modules[naam]
core = importlib.import_module("core")
assert core.DEFAULTS["LIBRARY_DIR"] == "/srv/eigen/stacks"
assert core.DEFAULTS["DATA_DIR"] == "/srv/eigen/appdata"
assert core.DEFAULTS["BACKUP_DIR"] == "/srv/eigen/backups"
# ── Meten en verplaatsen ─────────────────────────────────────────────────────
def test_meet_telt_bestanden_en_bytes(env, tmp_path):
from core import paden
d = _vul(tmp_path / "bron", {"a.txt": "12345", "sub/b.txt": "678"})
aantal, bytes_ = paden.meet(d)
assert (aantal, bytes_) == (2, 8)
assert paden.meet(tmp_path / "bestaat-niet") == (0, 0)
def test_verhuizen_kopieert_en_ruimt_daarna_op(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo", "sub/b.txt": "wereld"})
doel = tmp_path / "nieuw"
stappen = []
n = paden.verhuis(bron, doel, voortgang_fn=lambda g, t, l: stappen.append((g, t)))
assert n == 2
assert (doel / "a.txt").read_text() == "hallo"
assert (doel / "sub" / "b.txt").read_text() == "wereld"
assert not bron.exists(), "de bron hoort na een geslaagde verhuizing weg te zijn"
assert stappen and stappen[-1][0] == stappen[-1][1], "voortgang loopt niet vol"
def test_de_bron_blijft_staan_als_de_controle_faalt(env, tmp_path, monkeypatch):
"""Als er onderweg iets misgaat mag het origineel niet verdwijnen."""
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "hallo"})
doel = tmp_path / "nieuw"
# Doe alsof er minder is aangekomen dan verstuurd.
echt = paden.meet
def nep(p):
return (0, 0) if Path(p) == doel.resolve() else echt(p)
monkeypatch.setattr(paden, "meet", nep)
with pytest.raises(paden.PadFout, match="Controle mislukt"):
paden.verhuis(bron, doel)
assert (bron / "a.txt").exists(), "de bron is weg terwijl de controle faalde"
def test_doel_binnen_de_bron_wordt_geweigerd(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "x"})
with pytest.raises(paden.PadFout, match="binnen"):
paden.verhuis(bron, bron / "erin")
def test_zelfde_map_doet_niets(env, tmp_path):
from core import paden
bron = _vul(tmp_path / "oud", {"a.txt": "x"})
assert paden.verhuis(bron, bron) == 0
assert (bron / "a.txt").exists()
def test_symlinks_blijven_symlinks(env, tmp_path):
"""Een link volgen zou de inhoud dupliceren of buiten de boom kunnen wijzen."""
from core import paden
bron = _vul(tmp_path / "oud", {"echt.txt": "inhoud"})
os.symlink("echt.txt", bron / "link.txt")
doel = tmp_path / "nieuw"
paden.verhuis(bron, doel)
assert (doel / "link.txt").is_symlink()
assert os.readlink(doel / "link.txt") == "echt.txt"
# ── De stacks meeverhuizen ───────────────────────────────────────────────────
def test_paden_in_stacks_worden_omgeschreven(env, tmp_path):
"""Alleen bestanden verplaatsen laat elke stack naar zijn oude appdata
wijzen — die dan leeg is."""
from core import paden
lib = tmp_path / "stacks"
stack = lib / "sonarr"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text(
"services:\n sonarr:\n volumes:\n"
" - /opt/serverup/appdata/sonarr:/config\n", encoding="utf-8")
(stack / ".env").write_text("PAD=/opt/serverup/appdata/sonarr\n", encoding="utf-8")
(stack / ".serverup.json").write_text(json.dumps(
{"source": "sonarr", "values": {"appdata_dir": "/opt/serverup/appdata"}}))
n = paden.schrijf_paden_om(lib, {"/opt/serverup/appdata": "/srv/serverup/appdata"})
assert n == 3
assert "/srv/serverup/appdata/sonarr:/config" in (stack / "docker-compose.yml").read_text()
assert "/srv/serverup/appdata/sonarr" in (stack / ".env").read_text()
meta = json.loads((stack / ".serverup.json").read_text())
assert meta["values"]["appdata_dir"] == "/srv/serverup/appdata"
def test_een_pad_dat_er_op_lijkt_blijft_met_rust(env, tmp_path):
"""`/opt/serverup` vervangen mag `/opt/serverup-oud` niet aanraken."""
from core import paden
lib = tmp_path / "stacks"
stack = lib / "app"
stack.mkdir(parents=True)
(stack / "docker-compose.yml").write_text(
"services:\n a:\n volumes:\n"
" - /opt/serverup-oud/data:/config\n"
" - /opt/serverup/data:/data\n", encoding="utf-8")
paden.schrijf_paden_om(lib, {"/opt/serverup": "/srv/serverup"})
tekst = (stack / "docker-compose.yml").read_text()
assert "/opt/serverup-oud/data:/config" in tekst, "de buurmap is meegenomen"
assert "/srv/serverup/data:/data" in tekst
# ── Bereikbaarheid ───────────────────────────────────────────────────────────
def test_relatief_pad_wordt_geweigerd(env):
from core import paden
uit = paden.bereikbaar("relatief/pad")
assert not uit["ok"] and uit["reden"] == "geen_absoluut_pad"
def test_onschrijfbaar_pad_wordt_gemeld(env, tmp_path, monkeypatch):
from core import paden
monkeypatch.setattr(Path, "mkdir",
lambda *a, **k: (_ for _ in ()).throw(OSError("alleen-lezen")))
uit = paden.bereikbaar(str(tmp_path / "kan-niet"))
assert not uit["ok"] and uit["reden"] == "niet_schrijfbaar"
assert "alleen-lezen" in uit["detail"]
# ── Voortgang ────────────────────────────────────────────────────────────────
def test_voortgang_komt_door_de_api(client, env):
"""Bij tientallen gigabytes zegt "bezig…" niets; de balk komt hiervandaan."""
from core import jobs
login(client)
jid, _q = jobs.create("test")
jobs.voortgang(jid, 256, 1024, "bestand.txt")
d = client.get(f"/api/jobs/{jid}").get_json()
assert d["progress"] == {"done": 256, "total": 1024,
"label": "bestand.txt", "pct": 25}
def test_voortgang_deelt_niet_door_nul(env):
from core import jobs
jid, _q = jobs.create("test")
jobs.voortgang(jid, 0, 0, "")
assert jobs.stream(jid)["progress"]["pct"] == 0
# ── Het formulier stelt de ingestelde map voor ───────────────────────────────
def test_installatieformulier_gebruikt_de_ingestelde_appdata(client, env, monkeypatch):
"""266 sjablonen noemen /opt/serverup/appdata. Wat je ook instelde, dáár
kwam je data terecht.
De testomgeving zet DATA_DIR als omgevingsvariabele, en die wint van het
configuratiebestand — dus die verzetten we hier, niet de config.
"""
from core import git
csrf = login(client)
monkeypatch.setenv("DATA_DIR", "/srv/eigen/appdata")
bron = git.cache_dir("server-up") / "apps" / "testapp"
(bron / "files").mkdir(parents=True)
(bron / "template.json").write_text(json.dumps({
"kind": "compose",
"metadata": {"name": "Test", "description": "d", "tags": ["t"]},
"variables": [{"title": "Algemeen", "items": [
{"name": "service_name", "type": "str", "title": "Naam",
"default": "testapp", "required": True},
{"name": "appdata_dir", "type": "str", "title": "Appdata-map",
"default": "/opt/serverup/appdata", "required": True},
]}],
}), encoding="utf-8")
(bron / "files" / "compose.yaml").write_text(
"services:\n << service_name >>:\n image: x\n"
" volumes:\n - << appdata_dir >>/<< service_name >>:/config\n",
encoding="utf-8")
d = client.post("/api/store/preview",
json={"stack": "testapp", "repo_id": "server-up"},
headers={"X-CSRF-Token": csrf}).get_json()
veld = next(f for f in d["fields"] if f["name"] == "appdata_dir")
assert veld["default"] == "/srv/eigen/appdata"