UniFi's reparatiecontainer mount de stackmap in plaats van het losse script, zodat Docker er geen lege map van kan maken en exit 126 niet meer voorkomt. Bevat ook de snellere image-build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
246 lines
11 KiB
Python
246 lines
11 KiB
Python
"""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 /su-stack/init-mongo.sh ]" in bron
|
|
assert "Instellingen wijzigen" in bron
|
|
assert "exec bash /su-stack/init-mongo.sh" in bron
|
|
# En het script wordt via de map gemount, zodat Docker er geen lege map
|
|
# van kan maken.
|
|
assert "./:/su-stack:ro" in bron
|