diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec2a32..98c1c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,28 @@ -# v0.8.30-beta — Verbinden met een netwerk, en de updater neemt genoegen met het adres uit je adresbalk +# v0.8.30 — Je ziet nu waarom een app niet start + +Een app die niet opkwam meldde alleen `dependency failed to start: container X +is unhealthy`. Dat is een gevolg, geen oorzaak: de echte reden stond in de log +van díé container, en daar kwam je alleen met `docker logs` — als je wist dat +dat kon. Op een Raspberry Pi bleef een MongoDB die stopt met `Illegal +instruction` daardoor dagenlang een raadsel. + +- **De reden staat in hetzelfde joblog.** Mislukt het starten, dan haalt Server + Up van elke container die niet draait of ongezond is de uitvoer van de + healthcheck en de laatste logregels op en zet die eronder. Bij starten, + bijwerken, installeren en instellingen wijzigen. +- **De lege map die Docker achterlaat, wordt opgeruimd.** Wijst een bind-mount + naar een bestand dat nog niet in de stackmap staat, dan maakt Docker er een + map van. De container stopt daarna met `exit 126` (Is a directory), en het + opnieuw renderen struikelde over diezelfde map — waarmee het probleem + zichzelf in stand hield. Een lege map wordt nu opgeruimd; staat er iets in, + dan krijg je te horen wat er aan de hand is. +- **UniFi's reparatiecontainer zegt wat er mis is** als zijn script ontbreekt, + in plaats van een kaal exit-nummer. +- **Een bijgewerkt sjabloon valt op.** Opnieuw starten rendert een app niet + opnieuw, dus een verbeterd sjabloon bereikte een bestaande installatie nooit. + Het wijzigscherm laat nu zien welke images het sjabloon inmiddels gebruikt en + wat jouw app draait — een eigen versiekeuze telt daarbij niet als verschil. + Vier nieuwe apps voor iets wat de catalogus nog niet had: een machine ergens anders bij je eigen netwerk laten komen, en Proton Mail bruikbaar maken voor diff --git a/VERSION b/VERSION index a65aeb3..663a808 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.30-beta +0.8.30 diff --git a/apps/unifi-network/files/compose.yaml b/apps/unifi-network/files/compose.yaml index 244a82b..4a84ca0 100644 --- a/apps/unifi-network/files/compose.yaml +++ b/apps/unifi-network/files/compose.yaml @@ -80,7 +80,21 @@ services: # Dezelfde map als UniFi zelf: hierin staat system.properties, dat het # image alleen bij de allereerste start schrijft. - << appdata_dir >>/<< service_name >>/config:/unifi-config - entrypoint: ["/bin/bash", "/init-mongo.sh"] + # Eerst kijken óf het script er is. Ontbreekt het in de stackmap, dan mount + # Docker er een lege map overheen en stopt de container met "exit 126" — + # een getal waar niemand iets aan heeft. Nu staat er wat je eraan doet. + entrypoint: + - /bin/bash + - -c + - | + if [ ! -f /init-mongo.sh ]; then + echo "FOUT: /init-mongo.sh ontbreekt in de stackmap van deze app." + echo "Docker heeft er daarom een lege map van gemaakt." + echo "Herstel het met: Instellingen wijzigen -> opslaan, dan wordt" + echo "het sjabloon opnieuw uitgeschreven." + exit 1 + fi + exec bash /init-mongo.sh depends_on: << service_name >>-mongo: condition: service_healthy diff --git a/server-up/app.py b/server-up/app.py index 314f97a..d5958b5 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -762,6 +762,24 @@ def _ontbrekende_afhankelijkheden(meta: dict, bronnen: dict) -> list[dict]: return uit +def _log_waarom_mislukt(qq, pad: Path, naam: str): + """Zet de reden van een mislukte start in hetzelfde joblog. + + Compose zegt alleen `dependency failed to start: container X is unhealthy`. + De echte oorzaak staat in de log van díé container, en daar kwam je alleen + met `docker logs` — als je wist dat dat kon. Zo staat hij er meteen bij. + """ + try: + regels = docker.waarom_mislukt(pad, name=naam) + except Exception: + return + if not regels: + return + jobs.log(qq, "warn", "Dit zeggen de containers die niet opkwamen:") + for regel in regels: + jobs.log(qq, "dim", regel) + + def _auto_backup(qq, stack: str, config_key: str, reden: str): """Backup vóór een actie die bestanden overschrijft of weggooit. @@ -878,6 +896,8 @@ def api_stack_action(name, action): backups.prune(name, log_fn=lambda m: jobs.log(qq, "dim", m)) jobs.log(qq, "ok" if rc == 0 else "error", f"{action} {'klaar' if rc == 0 else 'mislukt'}") + if rc != 0: + _log_waarom_mislukt(qq, pdir, name) audit.log("stacks", action, "ok" if rc == 0 else "error", ref=name) jobs.finish(jid, "done" if rc == 0 else "error") except Exception as e: @@ -1114,6 +1134,10 @@ def api_stack_config(name): # formulier ze leeg tonen en zou opslaan ze wissen. waarden = {**waarden, **_geheimen_uit_env(d, meta)} return jsonify(ok=True, editable=True, + # Is het sjabloon veranderd sinds jij deze app uitrolde? + # Opnieuw starten rendert niet opnieuw, dus een verbeterd + # sjabloon bereikt je app pas als je hier op opslaan drukt. + image_drift=_sjabloon_image_verschil(d, meta, bron, waarden), fields=_annotate_fields(boilerplates.fields(bron)), values=waarden, network=meta.get("network", ""), ip=meta.get("ip", ""), @@ -1131,6 +1155,45 @@ def api_stack_config(name): source_repo=_sjabloon_herkomst(meta.get("repo_id", ""))) +def _sjabloon_image_verschil(d: Path, meta: dict, bron, waarden: dict) -> list[dict]: + """Welke images draait deze app anders dan het sjabloon nu zegt? + + Een app opnieuw starten rendert hem niet opnieuw: het compose-bestand in de + stackmap blijft staan zoals het ooit gerenderd is. Een sjabloon dat later + verbeterd wordt — een databaseversie die op een Raspberry Pi wél draait, + bijvoorbeeld — bereikt zo nooit een bestaande installatie, en aan niets is + te zien dat je op opslaan moet drukken. + + Alleen de images vergelijken en niet het hele bestand: de rest van de + stackmap krijgt na het renderen nog netwerken, extra's en geheimen + overheen, en dan verschilt er altijd wel iets zonder dat het iets betekent. + """ + if not bron: + return [] + try: + huidig = {i["service"]: i["image"] for i in + compose_transform.images_per_service(docker.read_compose(d))} + if not huidig: + return [] + import tempfile as _tmp + with _tmp.TemporaryDirectory() as t: + boilerplates.render_to_dir(bron, Path(t), waarden) + verse = docker.read_compose(Path(t)) + # De eigen versiekeuze hoort er overheen; anders meldt hij een verschil + # dat de gebruiker zelf heeft ingesteld. + tags = meta.get("image_tags") or {} + if tags: + verse = compose_transform.zet_image_tags(verse, tags) + nieuw = {i["service"]: i["image"] for i in + compose_transform.images_per_service(verse)} + except Exception: + return [] + + return [{"service": s, "nu": huidig[s], "sjabloon": nieuw[s]} + for s in sorted(nieuw) + if s in huidig and huidig[s] != nieuw[s]] + + def _sjabloon_herkomst(rid: str) -> dict: """Repo, branch en stand van de cache waar een sjabloon uit komt.""" if not rid: @@ -1354,6 +1417,8 @@ def api_stack_reconfigure(name): jobs.log(qq, "ok" if rc == 0 else "error", "Herstart met de nieuwe instellingen" if rc == 0 else "Starten mislukt \u2014 zet de backup terug via Backups") + if rc != 0: + _log_waarom_mislukt(qq, d, name) audit.log("stacks", "reconfigure", "ok" if rc == 0 else "error", ref=name, ip=ip) jobs.finish(jid, "done" if rc == 0 else "error") @@ -2486,6 +2551,8 @@ def api_store_install(): rc = docker.compose_up(dest, log_fn=lambda m: jobs.log(qq, "dim", m), name=inst) jobs.log(qq, "ok" if rc == 0 else "warn", "Gestart" if rc == 0 else "Start mislukt") + if rc != 0: + _log_waarom_mislukt(qq, dest, inst) audit.log("store", "install", "ok", ref=inst, detail={"env": list(env_overrides.keys()), "tags": image_tags}) jobs.finish(jid, "done") diff --git a/server-up/core/boilerplates.py b/server-up/core/boilerplates.py index e5c8be6..2bf7328 100644 --- a/server-up/core/boilerplates.py +++ b/server-up/core/boilerplates.py @@ -494,6 +494,18 @@ def render_to_dir(src: Path, dest: Path, values: dict[str, Any]) -> list[str]: rel = p.relative_to(files_dir) out = dest / rel out.parent.mkdir(parents=True, exist_ok=True) + # Docker maakt een lege map aan waar een compose-bestand een bestand + # mount dat nog niet bestaat. Daarna faalt de container met "exit 126" + # (Is a directory) en blijft dat zo, want het renderen struikelt hier + # over diezelfde map. Opruimen is veilig: hij is per definitie leeg, en + # wat erin hoort staat hier in de bron. + if out.is_dir() and not out.is_symlink(): + try: + out.rmdir() + except OSError as e: + raise BoilerplateError( + f"{rel} is een map en geen bestand; leeg of verwijder " + f"{out} en probeer opnieuw") from e if _looks_textual(p): try: txt = p.read_text(encoding="utf-8") diff --git a/server-up/core/docker.py b/server-up/core/docker.py index 82df4a4..46ae1da 100644 --- a/server-up/core/docker.py +++ b/server-up/core/docker.py @@ -307,6 +307,42 @@ def compose_up(d: Path, log_fn=None, name=None) -> int: return _stream(cmd, cwd=str(d), log_fn=log_fn) +def waarom_mislukt(d: Path, name=None, regels: int = 12) -> list[str]: + """Waarom kwam deze stack niet op? De reden staat in de containerlogs. + + Compose meldt alleen `dependency failed to start: container X is unhealthy`. + Dát is geen oorzaak maar een gevolg, en wie de echte reden wil weten moet + zelf `docker logs` gaan draaien — als hij weet dát dat kan. Een container + die op een Raspberry Pi stopt met "Illegal instruction" bleef zo dagen een + raadsel. Deze functie haalt van elke container die niet draait of ongezond + is de laatste regels op, zodat ze in hetzelfde joblog belanden. + """ + uit: list[str] = [] + for c in compose_ps(d, name=name): + naam = c.get("name") or "" + status = (c.get("status") or "").lower() + if not naam: + continue + if c.get("running") and "unhealthy" not in status: + continue + # Wat zei de healthcheck zelf? Vaak staat daar de kern al in. + r = _run([_bin(), "inspect", "-f", + "{{if .State.Health}}{{range .State.Health.Log}}{{.Output}}{{end}}{{end}}", + naam], timeout=15) + gezondheid = (r.stdout or "").strip().splitlines() + log = container_logs(naam, tail=regels).strip().splitlines() + if not gezondheid and not log: + continue + uit.append(f"── {naam}: {c.get('status') or 'gestopt'}") + for regel in gezondheid[-3:]: + if regel.strip(): + uit.append(f" healthcheck: {regel.strip()[:200]}") + for regel in log[-regels:]: + if regel.strip(): + uit.append(f" {regel.strip()[:200]}") + return uit + + def compose_down(d: Path, log_fn=None, volumes=False, name=None) -> int: f = find_compose(d) if not f: diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 8ec93f6..66bca49 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -1776,6 +1776,25 @@ tailwind.config = {

+ +
+ + + + + +
+ @@ -4013,6 +4032,7 @@ function app() { app_services: [], depends_on: [], extras: { volumes: [], devices: [] }, image_tags: {}, + image_drift: [], compatibility: { known: false, supported: true, architecture: '', reason: '' }, architecture_note: '' }; @@ -4271,6 +4291,7 @@ function app() { extras: { volumes: (d.extras||{}).volumes || [], devices: (d.extras||{}).devices || [] }, image_tags: { ...(d.image_tags || {}) }, + image_drift: d.image_drift || [], }; await this.loadAppNets(stack); this.fieldInfo = null; diff --git a/server-up/translations/en.json b/server-up/translations/en.json index 8bd1fe6..188d919 100644 --- a/server-up/translations/en.json +++ b/server-up/translations/en.json @@ -501,5 +501,6 @@ "remove_keep_data_hint": "Stops the containers and removes the app configuration. App data and named volumes are preserved.", "remove_all_hint": "Also removes app data and non-external named volumes. This cannot be undone.", "remove_done": "App removed", - "remove_failed": "Failed to remove app" + "remove_failed": "Failed to remove app", + "image_drift": "The template has been updated since you deployed this app. Saving adopts the new version; restarting does not." } diff --git a/server-up/translations/nl.json b/server-up/translations/nl.json index 589bcc5..5f52859 100644 --- a/server-up/translations/nl.json +++ b/server-up/translations/nl.json @@ -501,5 +501,6 @@ "remove_keep_data_hint": "Stopt de containers en verwijdert de appconfiguratie. Appdata en named volumes blijven bewaard.", "remove_all_hint": "Verwijdert ook appdata en niet-externe named volumes. Dit kan niet ongedaan worden gemaakt.", "remove_done": "App verwijderd", - "remove_failed": "App verwijderen mislukt" + "remove_failed": "App verwijderen mislukt", + "image_drift": "Het sjabloon is bijgewerkt sinds je deze app uitrolde. Opslaan neemt de nieuwe versie over; opnieuw starten doet dat niet." } diff --git a/tests/test_waarom_mislukt.py b/tests/test_waarom_mislukt.py new file mode 100644 index 0000000..73bf7d3 --- /dev/null +++ b/tests/test_waarom_mislukt.py @@ -0,0 +1,243 @@ +"""Als een stack niet opkomt, hoort de reden in het joblog te staan. + +Compose meldt `dependency failed to start: container X is unhealthy`. Dat is +een gevolg, geen oorzaak. De echte reden — een image dat de processor niet +aankan, een fout wachtwoord, een volle schijf — staat in de log van díé +container, en daar kwam je alleen met `docker logs`, als je wist dat dat kon. +""" +import sys +import time +from pathlib import Path + +WORTEL = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(WORTEL / "server-up")) + +from conftest import login + +ILLEGAL = ("WARNING: MongoDB requires ARMv8.2-A or higher\n" + "docker-entrypoint.sh: line 416: 26 Illegal instruction") + + +def _stack(env, naam="unifi-network"): + d = env["lib"] / naam + d.mkdir(parents=True, exist_ok=True) + (d / "docker-compose.yml").write_text( + "services:\n unifi-mongo:\n image: mongo:4.4.29\n") + return d + + +def _nep_docker(monkeypatch, docker, status="Up (unhealthy)", draait=True): + monkeypatch.setattr(docker, "compose_ps", lambda *a, **k: [ + {"name": "unifi-mongo", "service": "unifi-mongo", "state": "running", + "status": status, "ports": "", "running": draait}]) + monkeypatch.setattr(docker, "container_logs", lambda naam, tail=200: ILLEGAL) + monkeypatch.setattr(docker, "_run", lambda *a, **k: type( + "R", (), {"returncode": 0, "stdout": "ping kon niet verbinden\n", + "stderr": ""})()) + + +def test_reden_wordt_uit_de_container_gehaald(env, monkeypatch): + from core import docker + _nep_docker(monkeypatch, docker) + regels = docker.waarom_mislukt(_stack(env)) + tekst = "\n".join(regels) + assert "unifi-mongo" in tekst + assert "Illegal instruction" in tekst + assert "ARMv8.2-A" in tekst + # De healthcheck-uitvoer telt mee: daar staat de kern vaak al in. + assert "healthcheck: ping kon niet verbinden" in tekst + + +def test_draaiende_gezonde_container_levert_niets_op(env, monkeypatch): + """Alleen wat stuk is hoort in het log; anders verdrinkt de melding.""" + from core import docker + _nep_docker(monkeypatch, docker, status="Up 2 minutes") + assert docker.waarom_mislukt(_stack(env)) == [] + + +def test_gestopte_container_telt_ook_mee(env, monkeypatch): + from core import docker + _nep_docker(monkeypatch, docker, status="Exited (132)", draait=False) + assert "Illegal instruction" in "\n".join(docker.waarom_mislukt(_stack(env))) + + +def test_joblog_van_een_mislukte_start_bevat_de_reden(client, env, monkeypatch): + """De hele weg: op Start drukken, de start mislukt, en in hetzelfde venster + staat waaróm.""" + import app as app_module + from core import docker + csrf = login(client) + _stack(env) + _nep_docker(monkeypatch, docker) + monkeypatch.setattr(app_module.docker, "compose_up", lambda *a, **k: 1) + + r = client.post("/api/stacks/unifi-network/start", + json={}, headers={"X-CSRF-Token": csrf}) + assert r.status_code == 200, r.get_json() + jid = r.get_json()["job_id"] + einde = time.time() + 10 + while time.time() < einde: + d = client.get(f"/api/jobs/{jid}").get_json() or {} + if d.get("status") in ("done", "error"): + break + time.sleep(0.05) + + tekst = "\n".join(x["text"] for x in d.get("lines", [])) + assert "start mislukt" in tekst.lower() + assert "Dit zeggen de containers die niet opkwamen" in tekst + assert "Illegal instruction" in tekst + + +# ── Sjabloon gewijzigd, app nog niet ───────────────────────────────────────── + +def test_gewijzigd_sjabloon_wordt_gemeld_in_het_wijzigscherm(client, env): + """Precies de val van de Pi: het sjabloon kreeg een MongoDB-versie die daar + wél draait, maar de app bleef de oude starten omdat opnieuw starten niet + opnieuw rendert.""" + import json as _json + from core import git + login(client) + + d = env["lib"] / "unifi-network" + d.mkdir(parents=True, exist_ok=True) + (d / "docker-compose.yml").write_text( + "services:\n" + " unifi:\n image: lscr.io/linuxserver/unifi-network-application:latest\n" + " unifi-mongo:\n image: mongo:4.4.29\n") + (d / ".serverup.json").write_text(_json.dumps({ + "source": "unifi-network", "repo_id": "server-up", + "values": {"service_name": "unifi"}})) + + # Het sjabloon in de cache staat inmiddels op een versie die op een Pi werkt. + wortel = git.cache_dir("server-up") / "apps" / "unifi-network" + (wortel / "files").mkdir(parents=True, exist_ok=True) + (wortel / "template.json").write_text(_json.dumps({ + "kind": "compose", "metadata": {"name": "unifi"}, + "variables": [{"title": "Algemeen", "items": [ + {"name": "service_name", "type": "str", "title": "Naam", + "default": "unifi", "required": True}]}]}), encoding="utf-8") + (wortel / "files" / "compose.yaml").write_text( + "services:\n" + " << service_name >>:\n image: lscr.io/linuxserver/unifi-network-application:latest\n" + " << service_name >>-mongo:\n image: mongo:4.4.18\n", encoding="utf-8") + + d2 = client.get("/api/stacks/unifi-network/config").get_json() + assert d2["ok"] is True + drift = {v["service"]: v for v in d2["image_drift"]} + assert "unifi-mongo" in drift, d2["image_drift"] + assert drift["unifi-mongo"]["nu"] == "mongo:4.4.29" + assert drift["unifi-mongo"]["sjabloon"] == "mongo:4.4.18" + # Wat gelijk is hoort er niet bij; anders is de melding ruis. + assert "unifi" not in drift + + +def test_geen_melding_als_alles_gelijk_is(client, env): + import json as _json + from core import git + login(client) + d = env["lib"] / "web" + d.mkdir(parents=True, exist_ok=True) + (d / "docker-compose.yml").write_text("services:\n web:\n image: nginx:1.27\n") + (d / ".serverup.json").write_text(_json.dumps({ + "source": "web", "repo_id": "server-up", "values": {"service_name": "web"}})) + wortel = git.cache_dir("server-up") / "apps" / "web" + (wortel / "files").mkdir(parents=True, exist_ok=True) + (wortel / "template.json").write_text(_json.dumps({ + "kind": "compose", "metadata": {"name": "web"}, + "variables": [{"title": "Algemeen", "items": [ + {"name": "service_name", "type": "str", "title": "Naam", + "default": "web", "required": True}]}]}), encoding="utf-8") + (wortel / "files" / "compose.yaml").write_text( + "services:\n << service_name >>:\n image: nginx:1.27\n", encoding="utf-8") + + assert client.get("/api/stacks/web/config").get_json()["image_drift"] == [] + + +def test_eigen_versiekeuze_telt_niet_als_verschil(client, env): + """Wie zelf een versie vastzette, hoort daar geen melding over te krijgen.""" + import json as _json + from core import git + login(client) + d = env["lib"] / "web" + d.mkdir(parents=True, exist_ok=True) + (d / "docker-compose.yml").write_text("services:\n web:\n image: nginx:1.25-alpine\n") + (d / ".serverup.json").write_text(_json.dumps({ + "source": "web", "repo_id": "server-up", "values": {"service_name": "web"}, + "image_tags": {"web": "1.25-alpine"}})) + wortel = git.cache_dir("server-up") / "apps" / "web" + (wortel / "files").mkdir(parents=True, exist_ok=True) + (wortel / "template.json").write_text(_json.dumps({ + "kind": "compose", "metadata": {"name": "web"}, + "variables": [{"title": "Algemeen", "items": [ + {"name": "service_name", "type": "str", "title": "Naam", + "default": "web", "required": True}]}]}), encoding="utf-8") + (wortel / "files" / "compose.yaml").write_text( + "services:\n << service_name >>:\n image: nginx:1.27\n", encoding="utf-8") + + assert client.get("/api/stacks/web/config").get_json()["image_drift"] == [] + + +# ── De map die Docker maakt waar een bestand hoort ─────────────────────────── + +def test_render_ruimt_een_door_docker_gemaakte_map_op(env, tmp_path): + """Compose maakt een lege map aan als een bind-mount naar een bestand wijst + dat nog niet bestaat. De container stopt dan met exit 126 (Is a directory), + en zonder deze opruiming struikelt het opnieuw renderen over diezelfde map + — waarmee het probleem zichzelf in stand houdt.""" + import json as _json + from core import boilerplates as bp + + bron = tmp_path / "sjabloon" + (bron / "files").mkdir(parents=True) + (bron / "template.json").write_text(_json.dumps({ + "kind": "compose", "metadata": {"name": "x"}, + "variables": [{"title": "Algemeen", "items": [ + {"name": "service_name", "type": "str", "title": "Naam", + "default": "x", "required": True}]}]}), encoding="utf-8") + (bron / "files" / "compose.yaml").write_text( + "services:\n << service_name >>:\n image: nginx\n", encoding="utf-8") + (bron / "files" / "init.sh").write_text("#!/bin/bash\necho hoi\n", encoding="utf-8") + + doel = tmp_path / "stack" + (doel / "init.sh").mkdir(parents=True) # zoals Docker hem achterlaat + assert (doel / "init.sh").is_dir() + + bp.render_to_dir(bron, doel, {"service_name": "x"}) + assert (doel / "init.sh").is_file() + assert "echo hoi" in (doel / "init.sh").read_text() + + +def test_gevulde_map_wordt_niet_stilzwijgend_gewist(env, tmp_path): + """Alleen de lege map die Docker maakt mag weg. Staat er iets in, dan is + het van de gebruiker en hoort hij te horen wat er aan de hand is.""" + import json as _json + import pytest as _pytest + from core import boilerplates as bp + + bron = tmp_path / "sjabloon" + (bron / "files").mkdir(parents=True) + (bron / "template.json").write_text(_json.dumps({ + "kind": "compose", "metadata": {"name": "x"}, + "variables": [{"title": "Algemeen", "items": [ + {"name": "service_name", "type": "str", "title": "Naam", + "default": "x", "required": True}]}]}), encoding="utf-8") + (bron / "files" / "compose.yaml").write_text( + "services:\n << service_name >>:\n image: nginx\n", encoding="utf-8") + (bron / "files" / "init.sh").write_text("#!/bin/bash\n", encoding="utf-8") + + doel = tmp_path / "stack" + (doel / "init.sh").mkdir(parents=True) + (doel / "init.sh" / "iets.txt").write_text("van de gebruiker") + + with _pytest.raises(bp.BoilerplateError) as fout: + bp.render_to_dir(bron, doel, {"service_name": "x"}) + assert "map en geen bestand" in str(fout.value) + + +def test_unifi_init_zegt_wat_er_mis_is_in_plaats_van_exit_126(): + """Een ontbrekend script gaf 'exit 126' — een getal waar niemand iets aan + heeft. De container hoort te vertellen wat je eraan doet.""" + bron = (WORTEL / "apps" / "unifi-network" / "files" / "compose.yaml").read_text("utf-8") + assert "if [ ! -f /init-mongo.sh ]" in bron + assert "Instellingen wijzigen" in bron + assert "exec bash /init-mongo.sh" in bron