server-up/tests/test_waarom_mislukt.py
Ramon 0bcbe25646
Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled
Release / release (push) Successful in 3s
Deploy server-up (prod) / deploy (push) Has been cancelled
release: v0.8.30
Je ziet nu waarom een app niet start: de logs en healthcheck-uitvoer van de
containers die niet opkwamen belanden in hetzelfde joblog, het wijzigscherm
meldt een bijgewerkt sjabloon, en de lege map die Docker achterlaat waar een
bestand hoort wordt opgeruimd in plaats van het probleem in stand te houden.

Bevat ook de vier nieuwe verbindingsapps en de soepelere updater-adressen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:43:37 +02:00

243 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 /init-mongo.sh ]" in bron
assert "Instellingen wijzigen" in bron
assert "exec bash /init-mongo.sh" in bron