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
This commit is contained in:
parent
ec49337167
commit
0bcbe25646
10 changed files with 424 additions and 5 deletions
26
CHANGELOG.md
26
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
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.8.30-beta
|
||||
0.8.30
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -1776,6 +1776,25 @@ tailwind.config = {
|
|||
<span x-text="t('reconfigure_warning')"></span>
|
||||
</p>
|
||||
|
||||
<!-- Het sjabloon is veranderd sinds deze app is uitgerold. Opnieuw
|
||||
starten rendert niet opnieuw, dus zonder deze melding blijf je op de
|
||||
oude versie zitten zonder te weten dat er een betere klaarstaat. -->
|
||||
<div x-show="(install.image_drift || []).length"
|
||||
class="text-[11px] rounded-xl border border-ac/40 bg-ac/5 px-3 py-2 flex items-start gap-2">
|
||||
<span class="mdi mdi-package-up text-ac dark:text-ac-dark shrink-0"></span>
|
||||
<span class="min-w-0">
|
||||
<span x-text="t('image_drift')"></span>
|
||||
<template x-for="v in (install.image_drift || [])" :key="v.service">
|
||||
<span class="block font-mono text-[10px] mt-0.5">
|
||||
<span x-text="v.service"></span>:
|
||||
<span class="line-through opacity-70" x-text="v.nu"></span>
|
||||
<span class="mdi mdi-arrow-right"></span>
|
||||
<span class="text-ac dark:text-ac-dark" x-text="v.sjabloon"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit
|
||||
déze cache: loopt die achter, dan trek je een oude versie over een
|
||||
werkende stack heen zonder dat iets dat zegt. -->
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
243
tests/test_waarom_mislukt.py
Normal file
243
tests/test_waarom_mislukt.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue