"""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