412 lines
18 KiB
Python
412 lines
18 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) >= 190
|
||
|
||
|
||
@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"
|
||
|
||
|
||
# ── Apps die hun eigen database meebrengen ───────────────────────────────────
|
||
|
||
_DATABASE_IMAGES = ("postgres", "postgis", "mariadb", "mysql", "mongo",
|
||
"redis", "valkey")
|
||
|
||
|
||
def _bron(app: Path) -> str:
|
||
return (app / "files" / "compose.yaml").read_text(encoding="utf-8")
|
||
|
||
|
||
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
||
def test_extra_containers_dragen_de_servicenaam(app):
|
||
"""Een tweede container moet `<service_name>-…` heten.
|
||
|
||
De instantienaam is het enige wat een gebruiker kiest als hij een app twee
|
||
keer installeert. Heet de database dan gewoon `db`, dan botsen de twee
|
||
installaties op de containernaam en start de tweede niet.
|
||
"""
|
||
snaam = next((f.get("default") for f in bp.fields(app)
|
||
if f["name"] == "service_name"), None) or app.name
|
||
with tempfile.TemporaryDirectory() as t:
|
||
doc = _render(app, Path(t))
|
||
for naam in doc["services"]:
|
||
assert naam == snaam or naam.startswith(f"{snaam}-"), \
|
||
f"'{naam}' draagt de servicenaam '{snaam}' niet"
|
||
|
||
|
||
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
||
def test_bijgeleverde_database_heeft_geen_vast_wachtwoord(app):
|
||
"""Een database met een wachtwoord dat in het sjabloon staat, heeft bij
|
||
iedereen hetzelfde wachtwoord. Dat is geen wachtwoord."""
|
||
ruw = _bron(app)
|
||
heeft_db = any(f"image: {i}" in ruw for i in _DATABASE_IMAGES)
|
||
if not heeft_db:
|
||
return
|
||
for regel in ruw.splitlines():
|
||
r = regel.strip()
|
||
if not any(r.startswith(f"- {k}") for k in
|
||
("POSTGRES_PASSWORD", "MARIADB_PASSWORD", "MARIADB_ROOT_PASSWORD",
|
||
"MYSQL_PASSWORD", "MYSQL_ROOT_PASSWORD",
|
||
"MONGO_INITDB_ROOT_PASSWORD")):
|
||
continue
|
||
waarde = r.split("=", 1)[1] if "=" in r else ""
|
||
assert "<<" in waarde, f"vast databasewachtwoord: {r}"
|
||
|
||
|
||
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
||
def test_bijgeleverde_database_wordt_afgewacht(app):
|
||
"""Zonder depends_on start de app vóór zijn database en valt hij om op de
|
||
eerste verbinding."""
|
||
with tempfile.TemporaryDirectory() as t:
|
||
doc = _render(app, Path(t))
|
||
diensten = doc["services"]
|
||
dbs = {n for n, s in diensten.items()
|
||
if isinstance(s, dict)
|
||
and any(str(s.get("image", "")).startswith(i) for i in _DATABASE_IMAGES)}
|
||
if not dbs:
|
||
return
|
||
wacht = {d for s in diensten.values() if isinstance(s, dict)
|
||
for d in (s.get("depends_on") or [])}
|
||
assert dbs <= wacht, f"niemand wacht op {sorted(dbs - wacht)}"
|
||
|
||
|
||
# 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", "caddy", "zoraxy", "pangolin"}, # reverse proxies
|
||
443: {"nginx-proxy-manager", "traefik", "caddy", "zoraxy", "pangolin"},
|
||
53: {"adguard-home", "pihole"}, # dns-blokkers
|
||
# Twee manieren om een WireGuard-tunnel te draaien; je gebruikt er één.
|
||
51820: {"pangolin", "wg-easy"},
|
||
# 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",
|
||
"stirling-pdf", "traefik", "unifi-network"},
|
||
9000: {"mealie", "portainer"},
|
||
}
|
||
|
||
# Alle namen in apps/, om een arr-stack-veld aan zijn losse sjabloon te koppelen.
|
||
_APP_NAMEN = sorted((d.name for d in APPS), key=len, reverse=True)
|
||
|
||
|
||
def _stack_tegenhangers() -> dict[int, set[str]]:
|
||
"""Poorten die de ARR-stack deelt met het losse sjabloon van datzelfde
|
||
onderdeel.
|
||
|
||
Elk onderdeel van de stack bestaat ook als losse app. Dat zijn twee
|
||
verpakkingen van precies dezelfde container, en ze horen op dezelfde poort
|
||
te staan — anders krijg je een ander adres afhankelijk van hoe je hem
|
||
installeerde. Alleen dít soort botsing is toegestaan; twee verschillende
|
||
apps op één poort blijft een fout.
|
||
"""
|
||
uit: dict[int, set[str]] = {}
|
||
for f in bp.fields(WORTEL / "apps" / "arr-stack"):
|
||
if f.get("type") != "int" or not f["name"].startswith("port_"):
|
||
continue
|
||
if not isinstance(f.get("default"), int):
|
||
continue
|
||
rest = f["name"][len("port_"):]
|
||
# port_tdarr én port_tdarr_server horen allebei bij tdarr; de langste
|
||
# naam die past wint, zodat 'nzbget' niet aan 'nzb' blijft hangen. Een
|
||
# streepje in de mapnaam (cross-seed) valt weg in de veldnaam.
|
||
onderdeel = next((n for n in _APP_NAMEN
|
||
if rest in (k := n.replace("-", ""))
|
||
or rest.startswith(k + "_")), "")
|
||
if onderdeel:
|
||
uit.setdefault(f["default"], set()).update({"arr-stack", onderdeel})
|
||
return uit
|
||
|
||
|
||
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']}")
|
||
toegestaan = _stack_tegenhangers()
|
||
botsingen = {}
|
||
for poort, gebruikers in per_poort.items():
|
||
apps = {g.split(":")[0] for g in gebruikers}
|
||
mag = GEDEELDE_POORTEN.get(poort, set()) | toegestaan.get(poort, set())
|
||
if len(apps) > 1 and not apps <= mag:
|
||
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()) >= 45
|
||
|
||
|
||
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_zijn_eigen_services(schakelaar):
|
||
"""Aan betekent minstens één container erbij, uit betekent geen enkel spoor.
|
||
|
||
Eén container per onderdeel is de regel, maar niet de wet: Jellystat brengt
|
||
zijn eigen database mee. Wat wél moet kloppen is dat elke container die
|
||
verschijnt herkenbaar bij dít onderdeel hoort — anders sleept een schakelaar
|
||
stilzwijgend iets anders mee naar binnen.
|
||
"""
|
||
uit = {s: False for s in _arr_schakelaars()}
|
||
naam = schakelaar.replace("enable_", "")
|
||
|
||
doc = _arr_render({**uit, schakelaar: True})
|
||
diensten = set(doc["services"])
|
||
assert diensten, f"{schakelaar} levert niets op"
|
||
for d in diensten:
|
||
assert d == f"arr-{naam}" or d.startswith(f"arr-{naam}-"), \
|
||
f"{schakelaar} levert ook '{d}' op"
|
||
|
||
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
|
||
|
||
|
||
# ── Gedeeld netwerk over alle apps ───────────────────────────────────────────
|
||
|
||
from core import compose_transform as _ct
|
||
|
||
|
||
def _render_tekst(app: Path, overrides: dict | None = None) -> str:
|
||
velden = bp.fields(app)
|
||
waarden = {f["name"]: f["default"] for f in velden if f.get("default") is not None}
|
||
waarden.setdefault("service_name", app.name)
|
||
for f in velden:
|
||
if f.get("required") and not waarden.get(f["name"]):
|
||
waarden[f["name"]] = "testwaarde12345678901234567890ab"
|
||
waarden.update(overrides or {})
|
||
with tempfile.TemporaryDirectory() as t:
|
||
tmp = Path(t)
|
||
bp.render_to_dir(app, tmp, waarden)
|
||
return (tmp / "docker-compose.yml").read_text()
|
||
|
||
|
||
@pytest.mark.parametrize("app", APPS, ids=NAMEN)
|
||
def test_gedeeld_netwerk_botst_niet_met_network_mode(app):
|
||
"""Een service met network_mode deelt al andermans namespace; compose
|
||
weigert die samen met `networks`. Dat maakte Homebridge, Scrypted, Beszel
|
||
en de ARR-stack achter Gluetun oninstalleerbaar met de standaardkeuze
|
||
"Verbinden met andere apps"."""
|
||
doc = yaml.safe_load(_ct.add_shared_network(_render_tekst(app), "serverup"))
|
||
for naam, svc in doc["services"].items():
|
||
if svc.get("network_mode"):
|
||
assert "networks" not in svc, \
|
||
f"{naam}: network_mode={svc['network_mode']!r} én networks"
|
||
|
||
|
||
def test_gedeeld_netwerk_bij_arr_stack_met_vpn():
|
||
"""De downloadclients zitten dan op network_mode: service:…-gluetun."""
|
||
tekst = _render_tekst(ARR, {s: False for s in _arr_schakelaars()} | {
|
||
"enable_gluetun": True, "enable_qbittorrent": True,
|
||
"enable_sabnzbd": True, "enable_sonarr": True})
|
||
doc = yaml.safe_load(_ct.add_shared_network(tekst, "serverup"))
|
||
for client in ("qbittorrent", "sabnzbd"):
|
||
svc = doc["services"][f"arr-{client}"]
|
||
assert svc["network_mode"].startswith("service:")
|
||
assert "networks" not in svc
|
||
# Sonarr heeft geen network_mode en hoort er wél op te zitten.
|
||
assert "serverup" in doc["services"]["arr-sonarr"]["networks"]
|
||
assert doc["networks"]["serverup"] == {"external": True}
|
||
|
||
|
||
def test_alleen_network_mode_services_levert_geen_netwerkblok():
|
||
"""Zit geen enkele service op het gedeelde netwerk, dan hoeft het ook niet
|
||
gedeclareerd te worden."""
|
||
tekst = "services:\n a:\n image: x\n network_mode: host\n"
|
||
doc = yaml.safe_load(_ct.add_shared_network(tekst, "serverup"))
|
||
assert "networks" not in doc["services"]["a"]
|
||
assert "networks" not in doc
|
||
|
||
|
||
def test_huidige_versie_heeft_een_changelog_sectie():
|
||
"""release.yml haalt de release-notes uit CHANGELOG.md. Ontbreekt de sectie
|
||
voor de versie die je tagt, dan krijg je een lege release."""
|
||
versie = (WORTEL / "VERSION").read_text(encoding="utf-8").strip()
|
||
changelog = (WORTEL / "CHANGELOG.md").read_text(encoding="utf-8")
|
||
assert f"# v{versie}" in changelog, f"geen changelog-sectie voor {versie}"
|
||
|
||
|
||
def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
|
||
app = WORTEL / "apps" / "unifi-network"
|
||
with tempfile.TemporaryDirectory() as t:
|
||
tmp = Path(t)
|
||
doc = _render(app, tmp)
|
||
script = (tmp / "init-mongo.sh").read_text(encoding="utf-8")
|
||
|
||
mongo = doc["services"]["unifi-mongo"]
|
||
unifi = doc["services"]["unifi"]
|
||
assert mongo["image"] == "mongo:4.4.29"
|
||
assert "./init-mongo.sh:/docker-entrypoint-initdb.d/init-mongo.sh:ro" in mongo["volumes"]
|
||
assert any(v == "MONGO_AUTHSOURCE=admin" for v in unifi["environment"])
|
||
assert any(v == "MONGO_AUTHSOURCE=admin" for v in mongo["environment"])
|
||
assert unifi["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
|
||
assert mongo["healthcheck"]["test"][0] == "CMD-SHELL"
|
||
assert isinstance(mongo["healthcheck"]["test"][1], str)
|
||
assert "$${MONGO_INITDB_ROOT_PASSWORD}" in mongo["healthcheck"]["test"][1]
|
||
assert "db.createUser" in script
|
||
assert "clusterMonitor" in script
|
||
assert 'role: "dbOwner"' in script
|
||
assert bp.metadata(app)["architectures"] == ["amd64", "arm64"]
|
||
assert bp.metadata(app)["web"] == {"scheme": "https", "port": 8443}
|
||
|
||
AMD64_ALLEEN = {
|
||
"cal-com", "comfyui", "dawarich", "focalboard", "formbricks",
|
||
"mattermost", "pinchflat", "pixelfed", "tubearchivist", "unbound", "wazuh",
|
||
}
|
||
|
||
|
||
def test_catalogus_markeert_aantoonbaar_amd64_only_apps():
|
||
for naam in AMD64_ALLEEN:
|
||
meta = bp.metadata(WORTEL / "apps" / naam)
|
||
assert meta["architectures"] == ["amd64"], naam
|
||
assert "Raspberry Pi" in meta["architecture_note"], naam
|