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