All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 1m32s
De ARR-stack installeerde altijd dezelfde zeven containers. Nu kies je per onderdeel uit twintig: - Media: sonarr, radarr, lidarr, readarr, whisparr - Indexers: prowlarr, jackett, flaresolverr, bazarr - Downloaden: qbittorrent, sabnzbd, nzbget, gluetun (VPN) - Aanvullend: jellyseerr, recyclarr, unpackerr, tdarr, autobrr, maintainerr, huntarr Standaard aan: sonarr, radarr, prowlarr, bazarr, qbittorrent, jellyseerr. Gluetun: - Staat die aan, dan draaien de downloadclients in diens netwerknamespace zonder eigen poortmapping; anders lekt hun verkeer om de VPN heen. Hun webinterfaces komen op de poorten van de Gluetun-container. - qBittorrent en SABnzbd luisteren allebei standaard op 8080 en botsten daardoor achter een gedeelde namespace. qBittorrent draait nu intern op 8090. Groepsschakelaars werkten niet: - Een `toggle` op een groep in template.json was zelf geen variabele en kreeg dus nooit een startwaarde, waardoor elk schakelbaar onderdeel standaard uit stond en een stack met schakelaars leeg binnenkwam. fields() geeft de schakelaar nu mee als synthetisch bool-veld met een instelbare standaardstand (toggle_default), en de UI toont hem alleen in de kop van de groep in plaats van er ook nog eens onder. Tests: elk onderdeel levert precies een service op als het aan staat en geen spoor als het uit staat, de downloadclients zitten achter de VPN zonder eigen poorten, en de interne poorten achter Gluetun botsen niet. Poortbotsingen met bestaande apps rechtgezet (8090 -> 8097, 8095 -> 8098); 6881 en 5055 blijven gedeeld met de losse variant van dezelfde app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
206 lines
8.5 KiB
Python
206 lines
8.5 KiB
Python
"""Elke app in apps/ moet renderen naar geldige, startbare compose-YAML.
|
|
|
|
Deze suite draait over de echte templates, niet over voorbeelden. Zo valt een
|
|
kapot template op vóórdat iemand het probeert te installeren.
|
|
"""
|
|
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"))
|
|
|
|
from core import boilerplates as bp
|
|
|
|
APPS = sorted(d for d in (WORTEL / "apps").iterdir() if d.is_dir())
|
|
NAMEN = [d.name for d in APPS]
|
|
|
|
|
|
def _render(d: Path, tmp: Path) -> dict:
|
|
velden = bp.fields(d)
|
|
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
|
|
waarden.setdefault("service_name", d.name)
|
|
# Verplichte velden zonder default zijn geheimen; die vult de gebruiker in.
|
|
for f in velden:
|
|
if f.get("required") and not waarden.get(f["name"]):
|
|
waarden[f["name"]] = "testwaarde12345678901234567890ab"
|
|
bp.render_to_dir(d, tmp, waarden)
|
|
return yaml.safe_load((tmp / "docker-compose.yml").read_text())
|
|
|
|
|
|
def test_er_zijn_apps():
|
|
assert len(APPS) >= 60
|
|
|
|
|
|
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
|
def test_app_is_geldige_boilerplate(app):
|
|
assert bp.is_boilerplate(app), "template.json of files/ ontbreekt"
|
|
meta = bp.metadata(app)
|
|
assert meta["name"], "naam ontbreekt in template.json"
|
|
assert meta["description"], "omschrijving ontbreekt"
|
|
assert meta["tags"], "tags ontbreken"
|
|
assert any(f["name"] == "service_name" for f in bp.fields(app)), \
|
|
"service_name-variabele ontbreekt"
|
|
|
|
|
|
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
|
def test_app_rendert_naar_geldige_compose(app):
|
|
with tempfile.TemporaryDirectory() as t:
|
|
doc = _render(app, Path(t))
|
|
assert isinstance(doc, dict) and doc.get("services"), "geen services"
|
|
for naam, svc in doc["services"].items():
|
|
assert isinstance(svc, dict), f"{naam} is geen mapping"
|
|
assert svc.get("image") or svc.get("build"), f"{naam}: geen image of build"
|
|
|
|
|
|
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
|
def test_named_volumes_zijn_gedeclareerd(app):
|
|
"""Named volumes moeten in het top-level volumes-blok staan.
|
|
|
|
_drop_empty_mappings ruimde dat blok weg omdat een volumedeclaratie een
|
|
kale sleutel zonder waarde is. Zes apps (immich, ghost, miniflux,
|
|
paperless-ngx, unifi-network, vikunja) renderden daardoor naar een compose
|
|
die docker weigert met "refers to undefined volume".
|
|
"""
|
|
with tempfile.TemporaryDirectory() as t:
|
|
doc = _render(app, Path(t))
|
|
gedeclareerd = set((doc.get("volumes") or {}).keys())
|
|
for naam, svc in doc["services"].items():
|
|
for v in svc.get("volumes") or []:
|
|
if isinstance(v, str) and not v.startswith(("/", ".")):
|
|
bron = v.split(":")[0]
|
|
assert bron in gedeclareerd, \
|
|
f"{naam}: volume '{bron}' wordt gebruikt maar niet gedeclareerd"
|
|
|
|
|
|
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
|
def test_geen_onvervangen_variabelen(app):
|
|
"""Een typefout in een variabelenaam laat << … >> in het resultaat staan."""
|
|
with tempfile.TemporaryDirectory() as t:
|
|
tmp = Path(t)
|
|
_render(app, tmp)
|
|
tekst = (tmp / "docker-compose.yml").read_text()
|
|
assert "<<" not in tekst and ">>" not in tekst, "onvervangen variabele"
|
|
|
|
|
|
# Poorten die meerdere apps bewust delen: alternatieven voor dezelfde taak,
|
|
# die je nooit tegelijk draait. Alles daarbuiten hoort uniek te zijn.
|
|
GEDEELDE_POORTEN = {
|
|
80: {"nginx-proxy-manager", "traefik"}, # reverse proxies
|
|
443: {"nginx-proxy-manager", "traefik"},
|
|
53: {"adguard-home", "pihole"}, # dns-blokkers
|
|
5055: {"arr-stack", "jellyseerr"}, # zelfde app
|
|
6881: {"arr-stack", "qbittorrent"}, # zelfde app
|
|
# Hieronder: botsingen die er al waren vóór deze testsuite. De
|
|
# vrije-poortsuggestie bij installeren vangt ze op, dus ze zijn hinderlijk
|
|
# maar niet kapot. Nieuwe apps horen hier niet bij te komen.
|
|
3000: {"adguard-home", "forgejo", "grafana"},
|
|
8080: {"miniflux", "nextcloud-aio", "pihole", "qbittorrent",
|
|
"stirling-pdf", "traefik", "unifi-network"},
|
|
9000: {"mealie", "portainer"},
|
|
}
|
|
|
|
|
|
def test_poorten_botsen_niet_tussen_apps():
|
|
"""Twee apps met dezelfde standaardpoort geven bij installeren meteen een
|
|
conflict. De vrije-poortsuggestie vangt dat op, maar standaardwaarden die
|
|
al botsen zijn onnodig verwarrend."""
|
|
per_poort: dict[int, list[str]] = {}
|
|
for app in APPS:
|
|
for f in bp.fields(app):
|
|
if f.get("type") == "int" and "port" in f["name"].lower():
|
|
d = f.get("default")
|
|
if isinstance(d, int):
|
|
per_poort.setdefault(d, []).append(f"{app.name}:{f['name']}")
|
|
botsingen = {}
|
|
for poort, gebruikers in per_poort.items():
|
|
apps = {g.split(":")[0] for g in gebruikers}
|
|
if len(apps) > 1 and not apps <= GEDEELDE_POORTEN.get(poort, set()):
|
|
botsingen[poort] = sorted(apps)
|
|
assert not botsingen, f"onbedoeld dubbele standaardpoorten: {botsingen}"
|
|
|
|
|
|
# ── ARR-stack: schakelbare onderdelen ────────────────────────────────────────
|
|
|
|
ARR = WORTEL / "apps" / "arr-stack"
|
|
|
|
|
|
def _arr_render(overrides: dict) -> dict:
|
|
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())
|
|
|
|
|
|
def _arr_schakelaars() -> list[str]:
|
|
return [f["name"] for f in bp.fields(ARR) if f.get("is_group_toggle")]
|
|
|
|
|
|
def test_arr_heeft_veel_schakelbare_onderdelen():
|
|
assert len(_arr_schakelaars()) >= 20
|
|
|
|
|
|
def test_arr_standaard_levert_een_werkbare_basis():
|
|
doc = _arr_render({})
|
|
diensten = {s.split("-", 1)[1] for s in doc["services"]}
|
|
assert {"sonarr", "radarr", "prowlarr", "bazarr"} <= diensten
|
|
|
|
|
|
@pytest.mark.parametrize("schakelaar", _arr_schakelaars())
|
|
def test_arr_elk_onderdeel_levert_precies_een_service(schakelaar):
|
|
"""Aan betekent één service erbij, uit betekent geen enkel spoor."""
|
|
uit = {s: False for s in _arr_schakelaars()}
|
|
naam = schakelaar.replace("enable_", "")
|
|
|
|
doc = _arr_render({**uit, schakelaar: True})
|
|
assert set(doc["services"]) == {f"arr-{naam}"}
|
|
|
|
doc_uit = _arr_render(uit)
|
|
assert not (doc_uit or {}).get("services")
|
|
|
|
|
|
def test_arr_downloadclients_zitten_achter_de_vpn():
|
|
"""Met Gluetun delen de clients diens netwerknamespace en mogen ze geen
|
|
eigen poortmapping hebben — anders lekt het verkeer om de VPN heen."""
|
|
uit = {s: False for s in _arr_schakelaars()}
|
|
doc = _arr_render({**uit, "enable_gluetun": True, "enable_qbittorrent": True,
|
|
"enable_sabnzbd": True, "enable_nzbget": True})
|
|
for client in ("qbittorrent", "sabnzbd", "nzbget"):
|
|
svc = doc["services"][f"arr-{client}"]
|
|
assert svc.get("network_mode") == "service:arr-gluetun", client
|
|
assert "ports" not in svc, f"{client} heeft een eigen poort naast de VPN"
|
|
|
|
|
|
def test_arr_interne_poorten_botsen_niet_achter_de_vpn():
|
|
"""Alle clients delen één netwerknamespace, dus hun interne poorten moeten
|
|
verschillen. qBittorrent en SABnzbd wilden allebei 8080."""
|
|
uit = {s: False for s in _arr_schakelaars()}
|
|
doc = _arr_render({**uit, "enable_gluetun": True, "enable_qbittorrent": True,
|
|
"enable_sabnzbd": True, "enable_nzbget": 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}"
|
|
|
|
|
|
def test_arr_zonder_vpn_eigen_poorten():
|
|
uit = {s: False for s in _arr_schakelaars()}
|
|
doc = _arr_render({**uit, "enable_qbittorrent": True})
|
|
svc = doc["services"]["arr-qbittorrent"]
|
|
assert "network_mode" not in svc
|
|
assert svc.get("ports")
|
|
|
|
|
|
def test_groepsschakelaar_krijgt_een_startwaarde():
|
|
"""Een groepsschakelaar was geen veld en werd dus nooit ge??nitialiseerd,
|
|
waardoor elk onderdeel standaard uit stond in de installatiemodal."""
|
|
schakelaars = [f for f in bp.fields(ARR) if f.get("is_group_toggle")]
|
|
assert schakelaars
|
|
assert all(isinstance(f["default"], bool) for f in schakelaars)
|
|
aan = [f["name"] for f in schakelaars if f["default"]]
|
|
assert "enable_sonarr" in aan and "enable_radarr" in aan
|