All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m1s
- 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
199 lines
8.1 KiB
Python
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
|