Compare commits

..

No commits in common. "9fa08b79b7c46a00d9f6612789bbd024a8ce3c69" and "0bcbe2564641aa6b530b33e22f9780b2198b4006" have entirely different histories.

9 changed files with 25 additions and 200 deletions

View file

@ -1,39 +1,3 @@
# v0.8.31 — UniFi's reparatiescript kan niet meer zoekraken
`exit 126` bij `unifi-mongo-init`: het compose-bestand mountte het losse
bestand `./init-mongo.sh`, en als dat (nog) niet in de stackmap staat maakt
Docker er stilzwijgend een lege map van. Bash zegt dan "Is a directory", de
container stopt met een kaal exit-nummer, en het bleef zo — want ook het
opnieuw renderen struikelde over diezelfde map.
- **De hele stackmap wordt gemount in plaats van het losse bestand.** Die map
bestaat altijd, dus Docker kan er niets meer bij verzinnen. Ontbreekt het
script toch, dan zegt de container wat je eraan doet in plaats van 126.
- **Een lege map op de plek van een bestand wordt bij het renderen opgeruimd**,
zodat 'Instellingen wijzigen → opslaan' het probleem oplost in plaats van er
zelf op vast te lopen.
Verder in deze uitgave: bouwen duurt geen kwartier meer.
De volgorde in de Dockerfile bepaalt hoeveel er bij elke build opnieuw moet.
`ARG SU_VERSION` stond met een LABEL erachter bovenaan, en die waarde verandert
per uitgave — dus élke release gooide alles daarna weg: `apt-get`, de
Docker-CLI van 70 MB, de front-end-bestanden van het CDN. Er veranderden drie
regels Python en de machine bouwde het hele image opnieuw.
- **Versie en label staan onderaan.** Alles erboven blijft in de cache; een
uitgave bouwt alleen nog de laatste lagen.
- **De front-end-bestanden staan vóór de broncode.** Hun versies liggen vast,
dus ze worden niet meer bij elke commit opnieuw van het CDN gehaald — en de
push uploadt alleen nog de laag die echt veranderd is.
- **Een test bewaakt die volgorde**, want dit is precies het soort ding dat
ongemerkt terugsluipt bij de volgende wijziging.
- **Duidelijker melding als het image ontbreekt.** Een Forgejo-release ontstaat
bij de git-tag, maar het image komt uit de build — en die pusht alleen als de
deploy weet naar welke registry. Dan zie je een release die je niet kunt
installeren, en "heeft deze server toegang?" is dan een misleidend antwoord.
Draait je installatie op een zelfgebouwd image, dan zegt de melding dat ook.
# v0.8.30 — Je ziet nu waarom een app niet start # v0.8.30 — Je ziet nu waarom een app niet start
Een app die niet opkwam meldde alleen `dependency failed to start: container X Een app die niet opkwam meldde alleen `dependency failed to start: container X

View file

@ -4,16 +4,13 @@ COPY server-up/requirements.txt .
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
FROM python:3.12-slim FROM python:3.12-slim
# De volgorde hieronder is met opzet: alles wat zelden verandert staat boven, # Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
# alles wat bij elke commit verandert onderaan. Docker hergebruikt een laag # docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
# alleen zolang niets ervóór is gewijzigd, dus één regel op de verkeerde plek # Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
# kost bij elke build opnieuw de hele rit. ARG SU_VERSION=dev
# LABEL org.opencontainers.image.title="Server Up" \
# Wat hier eerder misging: `ARG SU_VERSION` met een LABEL erachter stond org.opencontainers.image.version="${SU_VERSION}" \
# bovenaan. Die waarde verandert per release, dus elke uitgave gooide álle org.opencontainers.image.source="https://git.example.com/bes-r/server-up"
# lagen erna weg — apt-get, de Docker-CLI van 70 MB, de front-end-bestanden,
# alles. Vandaar dat versie en label nu helemaal onderaan staan.
# util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker # util-linux levert `setpriv`, waarmee het entrypoint naar een gewone gebruiker
# afzakt zonder de capabilities te verliezen die Server Up nodig heeft. Het is # afzakt zonder de capabilities te verliezen die Server Up nodig heeft. Het is
# een Essential-pakket en dus al aanwezig, maar expliciet noemen zorgt dat een # een Essential-pakket en dus al aanwezig, maar expliciet noemen zorgt dat een
@ -43,11 +40,9 @@ RUN DPKG_ARCH=$(dpkg --print-architecture) \
COPY --from=build /inst /usr/local COPY --from=build /inst /usr/local
WORKDIR /app WORKDIR /app
COPY server-up/ ./
# De front-end-bestanden staan vóór de broncode: hun versies staan hier vast, COPY modules/ ./modules-bundled/
# dus deze lagen blijven staan zolang je ze niet zelf wijzigt. Stonden ze erna, COPY VERSION ./VERSION
# dan haalde elke commit ze opnieuw van het CDN én werden ze bij elke push
# opnieuw geüpload.
RUN mkdir -p static/fonts \ RUN mkdir -p static/fonts \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \ && curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" -o static/fonts/mdi.min.css \
&& curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \ && curl -fsSL "https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
@ -75,6 +70,7 @@ RUN mkdir -p /data/stacks /data/appdata /data/backups /data/git modules /root \
# eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die # eerste verbinding en bewaart de sleutel in /data/known_hosts. Wijzigt die
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar. # sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \ ENV PYTHONUNBUFFERED=1 PORT=5000 HOME=/root \
SU_VERSION=${SU_VERSION} \
SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \ SU_CONFIG=/data/config.json SU_AUDIT=/data/audit.db SU_GIT_CACHE=/data/git \
SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \ SU_SECRET=/data/secret.key SU_JOBS=/data/jobs \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts" GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
@ -87,22 +83,3 @@ HEALTHCHECK --interval=30s --timeout=8s --start-period=15s CMD curl -fs http://l
# SU_UID:SU_GID. Zonder die variabelen verandert er niets: dan blijft het root. # SU_UID:SU_GID. Zonder die variabelen verandert er niets: dan blijft het root.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["python","app.py"] CMD ["python","app.py"]
# ── Vanaf hier verandert er iets bij elke commit ─────────────────────────────
# Alles hierboven blijft in de cache staan; alleen deze laatste lagen worden
# opnieuw gebouwd. Daardoor duurt een release een halve minuut in plaats van
# een kwartier, en hoeft de push alleen deze lagen te uploaden.
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
# Build-arg: zet bij build naar de git-tag, zodat de image z'n eigen versie kent.
# docker build --build-arg SU_VERSION=$(git describe --tags --always) ...
# Forgejo Actions doet dit automatisch (zie server-up-deploy/README.md).
# Helemaal onderaan, want deze waarde verandert per uitgave en zou alles
# eronder ongeldig maken.
ARG SU_VERSION=dev
ENV SU_VERSION=${SU_VERSION}
LABEL org.opencontainers.image.title="Server Up" \
org.opencontainers.image.version="${SU_VERSION}" \
org.opencontainers.image.source="https://git.example.com/bes-r/server-up"

View file

@ -1 +1 @@
0.8.31 0.8.30

View file

@ -76,25 +76,25 @@ services:
- MONGO_DBNAME=unifi - MONGO_DBNAME=unifi
- MONGO_AUTHSOURCE=admin - MONGO_AUTHSOURCE=admin
volumes: volumes:
# De hele stackmap en niet het losse script. Een bind-mount naar een - ./init-mongo.sh:/init-mongo.sh:ro
# bestand dat (nog) niet bestaat laat Docker stilzwijgend een lege map
# aanmaken; de container stopt dan met "exit 126 — Is a directory" en
# blijft dat doen. Een map die er altijd is, kan dat niet overkomen.
- ./:/su-stack:ro
# Dezelfde map als UniFi zelf: hierin staat system.properties, dat het # Dezelfde map als UniFi zelf: hierin staat system.properties, dat het
# image alleen bij de allereerste start schrijft. # image alleen bij de allereerste start schrijft.
- << appdata_dir >>/<< service_name >>/config:/unifi-config - << appdata_dir >>/<< service_name >>/config:/unifi-config
# 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: entrypoint:
- /bin/bash - /bin/bash
- -c - -c
- | - |
if [ ! -f /su-stack/init-mongo.sh ]; then if [ ! -f /init-mongo.sh ]; then
echo "FOUT: init-mongo.sh ontbreekt in de stackmap van deze app." 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 "Herstel het met: Instellingen wijzigen -> opslaan, dan wordt"
echo "het sjabloon opnieuw uitgeschreven." echo "het sjabloon opnieuw uitgeschreven."
exit 1 exit 1
fi fi
exec bash /su-stack/init-mongo.sh exec bash /init-mongo.sh
depends_on: depends_on:
<< service_name >>-mongo: << service_name >>-mongo:
condition: service_healthy condition: service_healthy

View file

@ -186,22 +186,8 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
log_fn(f"Ophalen {image}") log_fn(f"Ophalen {image}")
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn) rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
if rc != 0: if rc != 0:
# "Not found" is bijna nooit een toegangsprobleem: er staat gewoon geen return False, (f"Ophalen van {image} mislukt. Bestaat die tag in de "
# image met die tag. De release bestaat wel — die maakt Forgejo bij de "registry, en heeft deze server er toegang toe?")
# git-tag — maar de build pusht alleen als de deploy-omgeving weet naar
# welke registry hij moet. Draait deze installatie bovendien op een
# zelfgebouwd image, dan valt er sowieso niets op te halen.
lokaal = inspect_self().get("image", "")
extra = ""
if lokaal and "/" not in lokaal.rsplit(":", 1)[0]:
extra = (f" Deze installatie draait '{lokaal}', een lokaal gebouwd "
"image; bijwerken gaat dan via install.sh en niet via deze "
"knop.")
return False, (
f"Ophalen van {image} mislukt. Er is een release met deze versie, "
"maar geen image met deze tag in de registry — controleer of de "
"build hem gepusht heeft (SU_IMAGE in de .env van je deploy) en of "
"deze server bij de registry mag." + extra)
return True, image return True, image

View file

@ -415,11 +415,7 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
"service_completed_successfully" "service_completed_successfully"
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy" assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
assert reparatie["restart"] == "no" assert reparatie["restart"] == "no"
# De hele map en niet het losse bestand: een bind-mount naar een bestand assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
# dat nog niet bestaat laat Docker een lege map aanmaken, en dan stopt de
# container met exit 126 zonder dat iets uitlegt waarom.
assert "./:/su-stack:ro" in reparatie["volumes"]
assert not any(v.startswith("./init-mongo.sh:") for v in reparatie["volumes"])
assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"]) assert any(v == "MONGO_HOST=unifi-mongo" for v in reparatie["environment"])
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent # De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
# geen 'root', en dan mislukte elke reparatie én elke mongodump. # geen 'root', en dan mislukte elke reparatie én elke mongodump.

View file

@ -1,59 +0,0 @@
"""De volgorde van de lagen in de Dockerfile bepaalt hoe lang een build duurt.
Docker hergebruikt een laag alleen zolang niets ervóór is gewijzigd. Stond het
versienummer bovenaan, dan gooide élke uitgave alles daarna weg: apt-get, de
Docker-CLI van 70 MB, de front-end-bestanden. Een release duurde daardoor een
kwartier terwijl er drie regels Python waren veranderd.
"""
from pathlib import Path
WORTEL = Path(__file__).resolve().parent.parent
REGELS = (WORTEL / "Dockerfile").read_text(encoding="utf-8").splitlines()
def _regel_van(fragment: str) -> int:
"""Het regelnummer van de eerste regel die dit fragment bevat."""
for i, r in enumerate(REGELS):
if fragment in r and not r.lstrip().startswith("#"):
return i
raise AssertionError(f"niet gevonden in de Dockerfile: {fragment}")
def test_de_broncode_komt_na_alles_wat_zelden_verandert():
"""De code wijzigt bij elke commit; wat daarvoor staat blijft in de cache."""
code = _regel_van("COPY server-up/ ./")
for eerder in ("apt-get update",
"download.docker.com",
"COPY --from=build /inst",
"static/fonts",
"static/vendor",
"mkdir -p /data/stacks"):
assert _regel_van(eerder) < code, \
f"'{eerder}' hoort vóór de broncode te staan, anders vervalt zijn cache"
def test_het_versienummer_staat_helemaal_onderaan():
"""SU_VERSION verandert per uitgave. Alles wat erná komt wordt opnieuw
gebouwd, dus er hoort niets meer achter te staan dan het label zelf."""
versie = _regel_van("ARG SU_VERSION")
for later in ("apt-get update", "download.docker.com", "static/vendor",
"COPY server-up/ ./", "ENTRYPOINT"):
assert _regel_van(later) < versie, \
f"'{later}' staat ná ARG SU_VERSION en wordt dus elke release herbouwd"
def test_de_frontendbestanden_worden_niet_door_de_broncode_overschreven():
"""Ze komen nu vóór `COPY server-up/`. Dat mag alleen als daar geen eigen
static-map in zit, want anders zou die de gedownloade bestanden vervangen."""
assert not (WORTEL / "server-up" / "static").exists(), (
"server-up/static bestaat nu wél; zet de downloads dan weer ná de COPY "
"of geef ze een andere plek")
def test_de_gepinde_versies_staan_er_nog():
"""De cache blijft alleen staan zolang deze regels niet wijzigen — dus als
ze veranderen hoort dat een bewuste keuze te zijn."""
tekst = "\n".join(REGELS)
for gepind in ("@mdi/font@7.4.47", "alpinejs@3.14.1", "htmx.org@2.0.3",
"tailwindcss.com/3.4.16", "docker-27.5.1.tgz", "compose/releases/download/v2.32.4"):
assert gepind in tekst, f"versie niet meer vastgezet: {gepind}"

View file

@ -117,39 +117,3 @@ def test_image_zonder_schema_blijft_gelijk(env):
# Een tag hoort er niet in; die komt van de release. # Een tag hoort er niet in; die komt van de release.
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up:0.8.28"}) env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up:0.8.28"})
assert selfupdate.image_repo({}) == "git.voorbeeld.nl/bes-r/server-up" assert selfupdate.image_repo({}) == "git.voorbeeld.nl/bes-r/server-up"
# ── Als het image er niet is ─────────────────────────────────────────────────
def test_mislukte_pull_noemt_de_waarschijnlijke_oorzaak(env, monkeypatch):
"""Een Forgejo-release ontstaat bij de git-tag, maar het image komt uit de
build en die pusht alleen als de deploy weet naar welke registry. Dan zie
je een release die je niet kunt installeren, en 'geen toegang' is dan een
misleidend antwoord."""
from core import selfupdate, docker
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
monkeypatch.setattr(selfupdate, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(selfupdate, "inspect_self",
lambda: {"image": "git.voorbeeld.nl/bes-r/server-up:0.8.29"})
monkeypatch.setattr(docker, "_stream", lambda *a, **k: 1)
ok, msg = selfupdate.pull("v0.8.30")
assert ok is False
assert "geen image met deze tag" in msg
assert "SU_IMAGE" in msg
def test_lokaal_gebouwd_image_wordt_als_zodanig_gemeld(env, monkeypatch):
"""Wie zijn image zelf bouwt kan met deze knop nooit iets ophalen; dat hoort
de melding te zeggen in plaats van naar de registry te wijzen."""
from core import selfupdate, docker
env["core"].patch({"UPDATE_IMAGE": "git.voorbeeld.nl/bes-r/server-up"})
monkeypatch.setattr(selfupdate, "_login", lambda log_fn=None: (True, ""))
monkeypatch.setattr(selfupdate, "inspect_self",
lambda: {"image": "server-up:latest"})
monkeypatch.setattr(docker, "_stream", lambda *a, **k: 1)
ok, msg = selfupdate.pull("v0.8.30")
assert ok is False
assert "lokaal gebouwd image" in msg
assert "install.sh" in msg

View file

@ -238,9 +238,6 @@ 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 """Een ontbrekend script gaf 'exit 126' — een getal waar niemand iets aan
heeft. De container hoort te vertellen wat je eraan doet.""" heeft. De container hoort te vertellen wat je eraan doet."""
bron = (WORTEL / "apps" / "unifi-network" / "files" / "compose.yaml").read_text("utf-8") bron = (WORTEL / "apps" / "unifi-network" / "files" / "compose.yaml").read_text("utf-8")
assert "if [ ! -f /su-stack/init-mongo.sh ]" in bron assert "if [ ! -f /init-mongo.sh ]" in bron
assert "Instellingen wijzigen" in bron assert "Instellingen wijzigen" in bron
assert "exec bash /su-stack/init-mongo.sh" in bron assert "exec bash /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