"""De *arr-sjablonen: los en gebundeld horen hetzelfde op te leveren. Elk onderdeel bestaat twee keer — als losse app in de store en als schakelaar in `apps/arr-stack`. Dat is precies het soort verdubbeling dat na een half jaar uit elkaar loopt: een image dat op één plek wordt bijgewerkt, een poort die alleen los verhuist. Beide komen daarom uit `tools/arr_sjablonen.py`, en deze tests bewaken dat dat zo blijft. """ import os import shutil import subprocess import sys import tempfile from pathlib import Path import pytest import yaml WORTEL = Path(__file__).resolve().parent.parent sys.path.insert(0, str(WORTEL / "server-up")) os.environ.setdefault("SU_CONFIG", "/tmp/su-arr-test.json") from core import boilerplates as bp # noqa: E402 sys.path.insert(0, str(WORTEL / "tools")) import arr_sjablonen as gen # noqa: E402 ARR = WORTEL / "apps" / "arr-stack" ONDERDELEN = gen.onderdelen() IDS = [a["dir"] for a in ONDERDELEN] def _schakelaars(): return [f["name"] for f in bp.fields(ARR) if f.get("is_group_toggle")] def _render(overrides): velden = bp.fields(ARR) waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None} waarden["service_name"] = "arr" waarden.update(overrides) with tempfile.TemporaryDirectory() as t: tmp = Path(t) bp.render_to_dir(ARR, tmp, waarden) return yaml.safe_load((tmp / "docker-compose.yml").read_text()) # ── De generator is de bron ────────────────────────────────────────────────── def test_sjablonen_zijn_gelijk_aan_de_generator(): """Handmatig één sjabloon bijwerken laat de andere achter. Draai `python3 tools/arr_sjablonen.py` en commit het resultaat.""" with tempfile.TemporaryDirectory() as t: tmp = Path(t) origineel = gen.APPS try: gen.APPS = tmp for app in gen.SPEC: gen.schrijf_los(app) gen.schrijf_stack() finally: gen.APPS = origineel anders = [] for pad in sorted(tmp.rglob("*")): if not pad.is_file(): continue echt = WORTEL / "apps" / pad.relative_to(tmp) if not echt.exists() or echt.read_bytes() != pad.read_bytes(): anders.append(str(pad.relative_to(tmp))) assert not anders, ("wijkt af van tools/arr_sjablonen.py: " + ", ".join(anders)) # ── Los en gebundeld lopen niet uiteen ─────────────────────────────────────── def _render_los(app): """Het losse sjabloon van dit onderdeel, met de standaardwaarden.""" d = WORTEL / "apps" / app["dir"] velden = bp.fields(d) waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None} with tempfile.TemporaryDirectory() as t: tmp = Path(t) bp.render_to_dir(d, tmp, waarden) return yaml.safe_load((tmp / "docker-compose.yml").read_text()) def _render_alleen(app): """Dit onderdeel uit de stack, met alle andere uit.""" return _render({s: False for s in _schakelaars()} | {f"enable_{gen._naam(app)}": True}) def _kenmerken(doc): """Wat er niet mag verschillen tussen los en gebundeld: welke images er draaien en op welke poort ze binnen de container luisteren.""" images, intern = set(), set() for svc in (doc.get("services") or {}).values(): images.add(svc.get("image")) for p in svc.get("ports") or []: intern.add(str(p).split(":", 1)[1]) return images, intern @pytest.mark.parametrize("app", ONDERDELEN, ids=IDS) def test_los_en_stack_leveren_dezelfde_container(app): """Hetzelfde image en dezelfde interne poort, hoe je ook installeert.""" assert _kenmerken(_render_los(app)) == _kenmerken(_render_alleen(app)), app["dir"] # ── Eén gedeelde /data-map ─────────────────────────────────────────────────── MEDIA_APPS = [d for d in sorted((WORTEL / "apps").iterdir()) if d.is_dir() and "data_root" in {f["name"] for f in bp.fields(d)}] @pytest.mark.parametrize("app", MEDIA_APPS, ids=[d.name for d in MEDIA_APPS]) def test_geen_losse_media_en_downloadmounts_meer(app): """Gescheiden mounts breken hardlinks: elke voltooide download wordt dan gekopieerd in plaats van gelinkt, ook al staat alles op één schijf.""" tekst = (app / "files" / "compose.yaml").read_text(encoding="utf-8") for verboden in (":/downloads", ":/tv", ":/movies", ":/music", ":/books"): assert verboden not in tekst, f"{app.name} mount nog {verboden}" @pytest.mark.parametrize("app", MEDIA_APPS, ids=[d.name for d in MEDIA_APPS]) def test_data_root_wordt_als_data_gekoppeld(app): tekst = (app / "files" / "compose.yaml").read_text(encoding="utf-8") assert "<< data_root >>:/data" in tekst or "<< data_root >>/media:/data/media" in tekst, \ f"{app.name} koppelt data_root niet op /data" def test_geen_enkel_sjabloon_gebruikt_nog_data_dir(): """`data_dir` betekende de appdata-map en werd verward met de gedeelde /data-map. Nu heten ze appdata_dir en data_root.""" fout = [] for d in sorted((WORTEL / "apps").iterdir()): if not d.is_dir(): continue if any(f["name"] == "data_dir" for f in bp.fields(d)): fout.append(d.name) assert not fout, fout # ── Het invulmenu van de stack ─────────────────────────────────────────────── def test_onderdelen_is_de_eerste_stap(): velden = bp.fields(ARR) stappen = [s for s in bp.STAPPEN if any(f["step"] == s for f in velden)] assert stappen[0] == "onderdelen" def test_elke_schakelaar_staat_in_de_stap_onderdelen(): """Stond de schakelaar in een andere stap, dan kreeg je hem twee keer te zien: één keer als kaart en één keer in de kop van zijn eigen groep.""" for f in bp.fields(ARR): if f.get("is_group_toggle"): assert f["step"] == "onderdelen", f["name"] def test_elke_schakelaar_heeft_een_kopje(): """Vijftig schakelaars zonder indeling is een lijst waar je in verdwaalt.""" for f in bp.fields(ARR): if f.get("is_group_toggle"): assert f.get("section"), f["name"] def test_de_stap_basis_blijft_kort(): """De poorten van vijftig onderdelen horen niet in het eerste scherm; ze staan goed en zijn als geavanceerd gemarkeerd.""" basis = [f for f in bp.fields(ARR) if f["step"] == "basis" and not f.get("advanced")] assert len(basis) <= 8, [f["name"] for f in basis] # ── Gluetun ────────────────────────────────────────────────────────────────── def test_alle_downloadclients_kunnen_achter_de_vpn(): aan = {f"enable_{gen._naam(a)}": True for a in ONDERDELEN if a["dir"] in gen.CLIENTS} doc = _render({s: False for s in _schakelaars()} | aan | {"enable_gluetun": True}) for naam in gen.CLIENTS: svc = doc["services"][f"arr-{gen._naam({'dir': naam})}"] assert svc.get("network_mode") == "service:arr-gluetun", naam assert "ports" not in svc, f"{naam} lekt om de VPN heen" def test_inkomende_poorten_gaan_niet_via_de_host(): """Achter een VPN regelt de aanbieder port forwarding. Publiceer je de inkomende poort tóch op de host, dan botsen twee torrentclients meteen op 6881 én komt het verkeer buiten de tunnel binnen.""" aan = {f"enable_{gen._naam(a)}": True for a in ONDERDELEN if a["dir"] in gen.CLIENTS} doc = _render({s: False for s in _schakelaars()} | aan | {"enable_gluetun": True}) intern = [str(p).split(":", 1)[1] for p in doc["services"]["arr-gluetun"]["ports"]] assert len(intern) == len(set(intern)), f"botsende interne poorten: {intern}" assert "6881" not in intern