server-up/tests/test_arr_sjablonen.py
Ramon 58d9be1f48
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m1s
v0.7.90-beta - ARR-stack opgesplitst, /data-mount, netwerkopties per container
- Elk *arr-onderdeel is nu ook een losse app in de store (46 nieuwe sjablonen);
  los en gebundeld komen uit één bron (tools/arr_sjablonen.py) met een test die
  bewaakt dat ze niet uiteen lopen
- Alles deelt één /data-map, zodat hardlinks werken; losse /tv-, /movies- en
  /downloads-mounts zijn eruit, UMASK=002 erbij
- Nieuwe eerste stap "Onderdelen" in het invulmenu, met kopjes per soort; de
  dubbele groepsschakelaar en de lege kaarten in Instellingen zijn weg
- Eigen IP-adres per container in plaats van per stack; containers zonder adres
  houden hun poortmapping. Ook Pangolin publiceert nu per container
- Huntarr vervangen door NeutArr (project offline na lekken, image bestaat niet
  meer), Maintainerr naar zijn nieuwe organisatie
- 31 apps toegevoegd uit het *arr-ecosysteem, alle images tegen hun registry
  gecontroleerd
- data_dir hernoemd naar appdata_dir in de hele catalogus, naast de nieuwe
  data_root; qBittorrent van poort 8080 naar 8097

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
2026-07-31 22:21:04 +02:00

199 lines
8.1 KiB
Python

"""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