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
230 lines
9.7 KiB
Python
230 lines
9.7 KiB
Python
"""Alle app-sjablonen doorlopen op fouten die pas bij het installeren opvallen.
|
|
|
|
Deze controles zijn ontstaan uit echte fouten: een Homepage die met
|
|
"Host validation failed" weigerde omdat de standaardwaarde een andere poort
|
|
noemde dan het poortveld, en velden die in het formulier stonden maar nergens
|
|
in het compose-bestand terechtkwamen.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
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-templates-test.json")
|
|
|
|
from core import boilerplates # noqa: E402
|
|
|
|
APPS = WORTEL / "apps"
|
|
VAR_RE = re.compile(r"<<\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?:\|[^>]*?)?>>")
|
|
BLOK_RE = re.compile(r"<%-?\s*(?:if|elif)\s+([^%]+?)\s*-?%>")
|
|
SLEUTELWOORDEN = {"and", "or", "not", "in", "is", "if", "elif",
|
|
"true", "false", "none", "defined"}
|
|
|
|
|
|
def _apps():
|
|
return sorted(d for d in APPS.iterdir()
|
|
if d.is_dir() and (d / "template.json").is_file())
|
|
|
|
|
|
def _compose_bestanden(d):
|
|
f = d / "files"
|
|
return sorted(p for p in f.iterdir()
|
|
if f.is_dir() and p.suffix in (".yaml", ".yml")
|
|
and "compose" in p.name.lower())
|
|
|
|
|
|
def _gerenderd(d):
|
|
"""Render elk compose-bestand met de standaardwaarden en geef de YAML."""
|
|
velden = boilerplates.fields(d)
|
|
waarden = {f["name"]: (f["default"] if f["default"] is not None else "")
|
|
for f in velden}
|
|
uit = []
|
|
for p in _compose_bestanden(d):
|
|
tekst = boilerplates.render_text(p.read_text(encoding="utf-8"), waarden)
|
|
uit.append((p, yaml.safe_load(tekst)))
|
|
return uit
|
|
|
|
|
|
IDS = [d.name for d in _apps()]
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_template_is_geldige_json(app):
|
|
json.loads((app / "template.json").read_text(encoding="utf-8"))
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_rendert_naar_geldige_compose(app):
|
|
for pad, doc in _gerenderd(app):
|
|
assert isinstance(doc, dict), f"{pad.name} levert geen mapping op"
|
|
assert doc.get("services"), f"{pad.name} heeft geen services"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_alle_variabelen_zijn_gedefinieerd(app):
|
|
"""Een variabele zonder veld rendert leeg; dat levert stilzwijgend een
|
|
kapotte instelling op in plaats van een foutmelding."""
|
|
namen = {f["name"] for f in boilerplates.fields(app)}
|
|
for p in _compose_bestanden(app):
|
|
ruw = p.read_text(encoding="utf-8")
|
|
gebruikt = set(VAR_RE.findall(ruw))
|
|
for blok in BLOK_RE.findall(ruw):
|
|
gebruikt |= (set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", blok))
|
|
- SLEUTELWOORDEN)
|
|
onbekend = sorted(gebruikt - namen)
|
|
assert not onbekend, f"{p.name}: niet gedefinieerd: {onbekend}"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_geen_velden_die_nergens_gebruikt_worden(app):
|
|
"""Een veld dat nergens terechtkomt, liegt tegen de gebruiker: je vult een
|
|
datamap in en je gegevens staan ergens anders."""
|
|
velden = [f for f in boilerplates.fields(app) if not f.get("is_group_toggle")]
|
|
gebruikt = set()
|
|
for p in _compose_bestanden(app):
|
|
ruw = p.read_text(encoding="utf-8")
|
|
gebruikt |= set(VAR_RE.findall(ruw))
|
|
for blok in BLOK_RE.findall(ruw):
|
|
gebruikt |= set(re.findall(r"[a-zA-Z_][a-zA-Z0-9_]*", blok))
|
|
# Ook andere bestanden in files/ mogen variabelen gebruiken.
|
|
for p in (app / "files").rglob("*"):
|
|
if p.is_file() and p.suffix not in (".yaml", ".yml"):
|
|
try:
|
|
gebruikt |= set(VAR_RE.findall(p.read_text(encoding="utf-8")))
|
|
except (UnicodeDecodeError, OSError):
|
|
pass
|
|
ongebruikt = sorted(f["name"] for f in velden if f["name"] not in gebruikt)
|
|
assert not ongebruikt, f"velden zonder werking: {ongebruikt}"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_named_volumes_staan_bovenaan(app):
|
|
"""Compose weigert een volume dat niet in het top-level blok staat."""
|
|
for pad, doc in _gerenderd(app):
|
|
top = set(doc.get("volumes") or {})
|
|
for snaam, svc in (doc.get("services") or {}).items():
|
|
for v in (svc or {}).get("volumes") or []:
|
|
if not isinstance(v, str):
|
|
continue
|
|
bron = v.split(":")[0]
|
|
if bron and not bron.startswith(("/", ".", "~", "$")):
|
|
assert bron in top, f"{pad.name}: {snaam} gebruikt '{bron}'"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_depends_on_verwijst_naar_bestaande_services(app):
|
|
for pad, doc in _gerenderd(app):
|
|
services = doc.get("services") or {}
|
|
for snaam, svc in services.items():
|
|
dep = (svc or {}).get("depends_on")
|
|
for d in (list(dep) if isinstance(dep, (list, dict)) else []):
|
|
assert d in services, f"{pad.name}: {snaam} → onbekend '{d}'"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_geen_dubbele_containernamen(app):
|
|
for pad, doc in _gerenderd(app):
|
|
namen = [svc.get("container_name") for svc in (doc.get("services") or {}).values()
|
|
if isinstance(svc, dict) and svc.get("container_name")]
|
|
assert len(namen) == len(set(namen)), f"{pad.name}: dubbel in {namen}"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_geen_poortconflict_binnen_een_stack(app):
|
|
"""Twee services die dezelfde hostpoort claimen levert een stack op die
|
|
half start. TCP en UDP op hetzelfde nummer mag wel."""
|
|
for pad, doc in _gerenderd(app):
|
|
bezet = {}
|
|
for snaam, svc in (doc.get("services") or {}).items():
|
|
for pr in (svc or {}).get("ports") or []:
|
|
s = str(pr)
|
|
proto = "udp" if s.endswith("/udp") else "tcp"
|
|
deel = s.replace("/udp", "").replace("/tcp", "").split(":")
|
|
if len(deel) >= 2 and deel[-2].strip('"').isdigit():
|
|
bezet.setdefault((deel[-2].strip('"'), proto), []).append(snaam)
|
|
dubbel = {k: v for k, v in bezet.items() if len(v) > 1}
|
|
assert not dubbel, f"{pad.name}: {dubbel}"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_poort_in_een_standaardwaarde_klopt_met_het_poortveld(app):
|
|
"""Homepage weigerde met 'Host validation failed' omdat allowed_hosts
|
|
poort 3001 noemde terwijl het poortveld op 3002 stond. Dezelfde fout zat in
|
|
baserow, hedgedoc en ntfy."""
|
|
velden = boilerplates.fields(app)
|
|
poorten = {f["default"] for f in velden
|
|
if f["type"] == "int" and "port" in f["name"]
|
|
and isinstance(f["default"], int)}
|
|
if not poorten:
|
|
return
|
|
for f in velden:
|
|
d = f.get("default")
|
|
if not isinstance(d, str):
|
|
continue
|
|
# Een koppelveld wijst juist naar een ándere app; dat die op zijn eigen
|
|
# poort staat is geen fout maar de bedoeling.
|
|
if f.get("connect"):
|
|
continue
|
|
for m in re.finditer(r"[a-zA-Z0-9_.-]+:(\d{2,5})\b", d):
|
|
genoemd = int(m.group(1))
|
|
# Alleen poorten die duidelijk bij deze app horen; een willekeurig
|
|
# ander nummer (een SMTP-poort bijvoorbeeld) valt hier buiten.
|
|
if any(abs(genoemd - p) < 1000 for p in poorten):
|
|
assert genoemd in poorten, (
|
|
f"{f['name']}={d!r} noemt poort {genoemd}, "
|
|
f"maar de poortvelden staan op {sorted(poorten)}")
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_wachtwoordvelden_zijn_als_geheim_gemarkeerd(app):
|
|
"""Anders staat het wachtwoord leesbaar in beeld en ontbreekt de knop om er
|
|
een te laten maken."""
|
|
for f in boilerplates.fields(app):
|
|
naam = f["name"].lower()
|
|
if any(w in naam for w in ("password", "secret", "token", "_key")):
|
|
assert f["secret"] is True, f"{f['name']} is niet als geheim gemarkeerd"
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_geen_vast_wachtwoord_in_het_sjabloon(app):
|
|
"""Acht apps hadden er een: 'immich_db_pass' voor iedereen die het
|
|
installeert, en 'admin' als beheerderswachtwoord van Grafana en Gotify.
|
|
Een standaardwaarde die overal hetzelfde is, is geen wachtwoord."""
|
|
for f in boilerplates.fields(app):
|
|
if not f.get("secret"):
|
|
continue
|
|
waarde = str(f.get("default") or "").strip()
|
|
assert not waarde, (
|
|
f"{f['name']} heeft een vaste waarde ({waarde!r}); maak het veld "
|
|
f"leeg en zet 'generate' aan, of laat de gebruiker het invullen")
|
|
|
|
|
|
@pytest.mark.parametrize("app", _apps(), ids=IDS)
|
|
def test_verplichte_geheimen_zijn_invulbaar_of_worden_gegenereerd(app):
|
|
"""Een verplicht geheim zonder standaardwaarde moet óf automatisch gevuld
|
|
worden, óf iets zijn dat de gebruiker zelf aanlevert. Wat overblijft is een
|
|
veld waar niemand een geldige waarde voor kan verzinnen."""
|
|
# Wachtwoorden waarmee je zelf inlogt en tokens die je elders ophaalt vult
|
|
# de gebruiker bewust zelf in.
|
|
eigen_invoer = ("admin_password", "web_password", "ui_password",
|
|
"superuser_password", "password_hash", "hass_token",
|
|
"forgejo_token", "adminpass")
|
|
for f in boilerplates.fields(app):
|
|
if not (f.get("required") and f.get("secret")):
|
|
continue
|
|
if str(f.get("default") or "").strip():
|
|
continue
|
|
# Een API-sleutel komt per definitie uit de app waar je naartoe wijst;
|
|
# die kun je hier niet verzinnen, alleen overtypen.
|
|
if f["name"].endswith(("_apikey", "_api_key")):
|
|
continue
|
|
if f.get("generate") or f["name"] in eigen_invoer:
|
|
continue
|
|
pytest.fail(f"{f['name']}: verplicht geheim zonder waarde, zonder "
|
|
f"generator en niet als eigen invoer aangemerkt")
|