All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 2m22s
- Catalogus van 142 naar 197 apps. Het zwaartepunt lag na het ARR-werk op media (42 apps) terwijl communicatie er 4 had, AI 4, financien 3 en foto's 2 - Drie nieuwe categorieen: Statistiek, Zakelijk en Spellen - Homelab-gereedschap: Dockge, Komodo, Glance, Gatus, Scrutiny, Backrest, Headscale, Zoraxy, CrowdSec, NetAlertX, Pocket ID, Authentik - Communicatie van 4 naar 12: Mattermost, Synapse, Element, Listmonk, Mumble, Roundcube, Stalwart - Notities: Outline, Trilium, SilverBullet, Readeck, Docmost, Kiwix - Verder LibreChat, AnythingLLM, LocalAI, Ghostfolio, Wallos, Maybe, Lychee, Piwigo, Homebox, wger, Donetick, Dawarich, Gitea, Adminer, code-server, Woodpecker, Semaphore, Opengist, MinIO, Pingvin Share, Umami, Matomo, Odoo, FreeScout, EspoCRM, RomM, Minecraft, Leantime, PrivateBin, Emby, ConvertX - Drie bestaande apps waren niet installeerbaar: Forgejo wees naar een latest-tag die dat project niet publiceert, Planka naar een tag 1 die niet bestaat, en Baby Buddy naar een ghcr-pad dat er niet is. Gevonden door alle 213 images in de catalogus langs de registries te halen - Twee testregels waren te krap: een config.yaml in files/ die geen compose is telde niet mee voor "veld wordt gebruikt", en geheimen die de gebruiker zelf aanlevert stonden in een handmatige namenlijst. Dat laatste is nu een vlag in het sjabloon: "eigen_invoer": true - Nieuwe controles voor de 36 apps met een eigen database: elke extra container draagt de servicenaam als voorvoegsel, geen vaste databasewachtwoorden, en er wacht altijd iemand met depends_on op de database Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
237 lines
10 KiB
Python
237 lines
10 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 — inclusief
|
|
# YAML dat geen compose is, zoals de config.yaml die Headscale en Gatus
|
|
# meekrijgen. Die vielen eerder tussen wal en schip: niet gescand als
|
|
# compose, en overgeslagen omdat ze op .yaml eindigen.
|
|
composes = set(_compose_bestanden(app))
|
|
for p in (app / "files").rglob("*"):
|
|
if p.is_file() and p not in composes:
|
|
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. Nieuwe sjablonen zeggen dat met
|
|
# `"eigen_invoer": true`; de namen hieronder zijn van vóór die vlag.
|
|
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
|
|
if f.get("eigen_invoer"):
|
|
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")
|