All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m46s
Het linuxserver-image schrijft /config/data/system.properties alleen bij de allereerste start. De authSource en het wachtwoord die daarna in Compose veranderden kwamen er nooit in, dus UniFi bleef verbinden met de gegevens van de eerste installatie — MongoDB antwoordde met UserNotFound: unifi@unifi. - De reparatiecontainer zet db.mongo.uri, statdb.mongo.uri, db.mongo.local en unifi.db.name opnieuw uit de actuele omgeving; overige instellingen blijven. - Gebruikersnaam en wachtwoord worden url-gecodeerd in de URI gezet. - Het bestand wordt op zijn plaats overschreven, dus eigenaar en rechten blijven van UniFi zelf. - Het wachtwoord uit system.properties telt mee als inlogpoging, zodat een afgeweken .env de stack niet onherstelbaar maakt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
563 lines
25 KiB
Python
563 lines
25 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 re
|
||
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"))
|
||
|
||
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 _controleer_javascript_van_initscript(script: str):
|
||
"""`bash -n` ziet de JavaScript in de heredoc niet — node wel.
|
||
|
||
Een typefout daarin blijkt anders pas op de server, wanneer de reparatie
|
||
van de database stilletjes niets doet.
|
||
"""
|
||
import shutil
|
||
node = shutil.which("node")
|
||
if not node:
|
||
pytest.skip("node niet beschikbaar")
|
||
binnen = script.split("<<EOF\n", 1)[1].split("\nEOF", 1)[0]
|
||
# De shell vult ${...} in vóór het script de mongo-shell in gaat.
|
||
js = re.sub(r"\$\{[^}]+\}", "x", binnen)
|
||
r = subprocess.run([node, "--check", "-"], input=js, text=True,
|
||
capture_output=True)
|
||
assert r.returncode == 0, r.stderr
|
||
|
||
|
||
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"]
|
||
reparatie = doc["services"]["unifi-mongo-init"]
|
||
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 unifi["depends_on"]["unifi-mongo-init"]["condition"] == \
|
||
"service_completed_successfully"
|
||
assert reparatie["image"] == "mongo:4.4.29"
|
||
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
|
||
assert reparatie["restart"] == "no"
|
||
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
|
||
assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
|
||
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
|
||
# geen 'root', en dan mislukte elke reparatie én elke mongodump.
|
||
assert any(v == "MONGO_INITDB_ROOT_USERNAME=unifi" for v in mongo["environment"])
|
||
assert any(v == "MONGO_INITDB_ROOT_USERNAME=unifi" for v in reparatie["environment"])
|
||
# De healthcheck mag geen inloggegevens gebruiken: bestaat de beheerder
|
||
# niet, dan blijft de container ongezond en start de reparatie nooit.
|
||
assert mongo["healthcheck"]["test"][0] == "CMD-SHELL"
|
||
assert isinstance(mongo["healthcheck"]["test"][1], str)
|
||
assert "--username" not in mongo["healthcheck"]["test"][1]
|
||
assert "ping" in mongo["healthcheck"]["test"][1]
|
||
assert "createUser" in script
|
||
assert "clusterMonitor" in script
|
||
assert 'role: "dbOwner"' in script
|
||
assert "getUser" in script and "updateUser" in script
|
||
assert "grantRolesToUser" in script
|
||
# Rollen die de backup nodig heeft voor mongodump/mongorestore.
|
||
assert 'role: "backup"' in script and 'role: "restore"' in script
|
||
# Meerdere beheerders proberen, inclusief de naam uit het eerste sjabloon.
|
||
assert '"root", "unifi"' in script
|
||
assert "ensureUser(authBron)" in script
|
||
assert "ensureUser(appDatabase)" in script
|
||
syntaxis = subprocess.run(["bash", "-n"], input=script, text=True,
|
||
capture_output=True)
|
||
assert syntaxis.returncode == 0, syntaxis.stderr
|
||
_controleer_javascript_van_initscript(script)
|
||
assert bp.metadata(app)["architectures"] == ["amd64", "arm64"]
|
||
assert bp.metadata(app)["web"] == {"scheme": "https", "port": 8443}
|
||
|
||
|
||
def test_unifi_zet_zijn_system_properties_bij_elke_start_goed(tmp_path):
|
||
"""Het image schrijft /config/data/system.properties alleen bij de eerste
|
||
start ("Only evaluated on first run"). Een later toegevoegde authSource of
|
||
een gewijzigd wachtwoord kwam daar dus nooit in, en UniFi bleef met de oude
|
||
gegevens verbinden — met UserNotFound tot gevolg.
|
||
"""
|
||
app = WORTEL / "apps" / "unifi-network"
|
||
doc = _render(app, tmp_path)
|
||
reparatie = doc["services"]["unifi-mongo-init"]
|
||
assert any(v.endswith(":/unifi-config") for v in reparatie["volumes"])
|
||
|
||
# De reparatie draaien tegen een bestaand bestand van vóór de authSource.
|
||
config = tmp_path / "unifi-config" / "data"
|
||
config.mkdir(parents=True)
|
||
props = config / "system.properties"
|
||
props.write_text(
|
||
"unifi.https.port=8443\n"
|
||
"db.mongo.local=false\n"
|
||
"db.mongo.uri=mongodb://unifi:oudgeheim@unifi-mongo:27017/unifi?tls=false\n"
|
||
"statdb.mongo.uri=mongodb://unifi:oudgeheim@unifi-mongo:27017/unifi_stat?tls=false\n"
|
||
"unifi.db.name=unifi\n", encoding="utf-8")
|
||
|
||
nep = tmp_path / "nepbin"
|
||
nep.mkdir()
|
||
(nep / "mongo").write_text("#!/bin/bash\ncat > /dev/null\n", encoding="utf-8")
|
||
(nep / "mongo").chmod(0o755)
|
||
omgeving = {
|
||
"PATH": f"{nep}:/usr/bin:/bin",
|
||
"MONGO_INITDB_ROOT_USERNAME": "unifi", "MONGO_INITDB_ROOT_PASSWORD": "nieuw@geheim",
|
||
"MONGO_USER": "unifi", "MONGO_PASS": "nieuw@geheim",
|
||
"MONGO_HOST": "unifi-mongo", "MONGO_PORT": "27017",
|
||
"MONGO_DBNAME": "unifi", "MONGO_AUTHSOURCE": "admin",
|
||
"UNIFI_CONFIG_DIR": str(tmp_path / "unifi-config"),
|
||
}
|
||
r = subprocess.run(["bash", str(tmp_path / "init-mongo.sh")],
|
||
capture_output=True, text=True, env=omgeving, timeout=30)
|
||
assert r.returncode == 0, r.stdout + r.stderr
|
||
|
||
regels = dict(l.split("=", 1) for l in props.read_text().strip().splitlines())
|
||
assert "authSource=admin" in regels["db.mongo.uri"]
|
||
assert "authSource=admin" in regels["statdb.mongo.uri"]
|
||
# Bijzondere tekens horen ge-url-codeerd te zijn, anders knipt de @ de URI
|
||
# doormidden.
|
||
assert "nieuw%40geheim" in regels["db.mongo.uri"]
|
||
assert "oudgeheim" not in props.read_text()
|
||
assert regels["statdb.mongo.uri"].split("?")[0].endswith("/unifi_stat")
|
||
# Instellingen die niet over de database gaan blijven staan.
|
||
assert regels["unifi.https.port"] == "8443"
|
||
|
||
|
||
def test_unifi_probeert_ook_het_wachtwoord_uit_system_properties(tmp_path):
|
||
"""Wijkt het wachtwoord in .env af van dat waarmee de database ooit is
|
||
aangemaakt, dan is het wachtwoord in system.properties het enige waarmee we
|
||
nog binnenkomen — en dus de enige weg om alles weer gelijk te trekken."""
|
||
_render(WORTEL / "apps" / "unifi-network", tmp_path)
|
||
config = tmp_path / "unifi-config" / "data"
|
||
config.mkdir(parents=True)
|
||
(config / "system.properties").write_text(
|
||
"db.mongo.uri=mongodb://unifi:oud%40geheim@unifi-mongo:27017/unifi?tls=false\n",
|
||
encoding="utf-8")
|
||
|
||
nep = tmp_path / "nepbin"
|
||
nep.mkdir()
|
||
# De 'mongo-shell' schrijft op wat hij binnenkrijgt, zodat we kunnen zien
|
||
# welke wachtwoorden geprobeerd worden.
|
||
(nep / "mongo").write_text(
|
||
f"#!/bin/bash\ncat > {tmp_path / 'ontvangen.js'}\n", encoding="utf-8")
|
||
(nep / "mongo").chmod(0o755)
|
||
r = subprocess.run(
|
||
["bash", str(tmp_path / "init-mongo.sh")], capture_output=True, text=True,
|
||
env={"PATH": f"{nep}:/usr/bin:/bin", "MONGO_INITDB_ROOT_USERNAME": "unifi",
|
||
"MONGO_INITDB_ROOT_PASSWORD": "nieuwgeheim", "MONGO_USER": "unifi",
|
||
"MONGO_PASS": "nieuwgeheim", "MONGO_HOST": "unifi-mongo",
|
||
"MONGO_PORT": "27017", "MONGO_DBNAME": "unifi",
|
||
"MONGO_AUTHSOURCE": "admin",
|
||
"UNIFI_CONFIG_DIR": str(tmp_path / "unifi-config")},
|
||
timeout=30)
|
||
assert r.returncode == 0, r.stdout + r.stderr
|
||
|
||
js = (tmp_path / "ontvangen.js").read_text()
|
||
# %40 hoort weer een @ te zijn voordat MongoDB hem te zien krijgt.
|
||
assert 'const oudWachtwoord = "oud@geheim";' in js
|
||
assert 'const beheerWachtwoord = "nieuwgeheim";' in js
|
||
|
||
|
||
def test_unifi_reparatie_werkt_ook_zonder_bestaande_system_properties(tmp_path):
|
||
"""Bij een verse installatie schrijft het image het bestand zelf; de
|
||
reparatie mag daar niet over struikelen."""
|
||
_render(WORTEL / "apps" / "unifi-network", tmp_path)
|
||
nep = tmp_path / "nepbin"
|
||
nep.mkdir()
|
||
(nep / "mongo").write_text("#!/bin/bash\ncat > /dev/null\n", encoding="utf-8")
|
||
(nep / "mongo").chmod(0o755)
|
||
r = subprocess.run(
|
||
["bash", str(tmp_path / "init-mongo.sh")], capture_output=True, text=True,
|
||
env={"PATH": f"{nep}:/usr/bin:/bin", "MONGO_INITDB_ROOT_USERNAME": "unifi",
|
||
"MONGO_INITDB_ROOT_PASSWORD": "x", "MONGO_USER": "unifi", "MONGO_PASS": "x",
|
||
"MONGO_HOST": "unifi-mongo", "MONGO_DBNAME": "unifi",
|
||
"MONGO_AUTHSOURCE": "admin",
|
||
"UNIFI_CONFIG_DIR": str(tmp_path / "bestaat-niet")},
|
||
timeout=30)
|
||
assert r.returncode == 0, r.stdout + r.stderr
|
||
|
||
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
|