Compare commits

..

3 commits

Author SHA1 Message Date
Ramon
9fa08b79b7 release: v0.8.31
Some checks failed
Deploy server-up (dev) / deploy (push) Has been cancelled
Release / release (push) Successful in 3s
Deploy server-up (prod) / deploy (push) Successful in 12m4s
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
2026-08-07 16:10:42 +02:00
Ramon
14515143fc perf(build): image bouwt niet meer alles opnieuw bij elke uitgave
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 4m51s
ARG SU_VERSION stond bovenaan met een LABEL erachter. Die waarde verandert
per release, dus alle lagen erna vervielen: apt-get, de Docker-CLI van 70 MB
en de front-end-bestanden. Versie en label staan nu onderaan, en de assets
vóór de broncode zodat ze niet bij elke commit opnieuw van het CDN komen.

Ook: een mislukte pull noemt nu de waarschijnlijke oorzaak (geen gepusht
image) in plaats van alleen naar toegangsrechten te wijzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 16:05:46 +02:00
Ramon
f86d7d35a6 chore: dev terug op een betanummer (0.8.31-beta)
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 6m26s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
2026-08-07 15:43:50 +02:00
9 changed files with 200 additions and 25 deletions

View file

@ -1,3 +1,39 @@
# 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
Een app die niet opkwam meldde alleen `dependency failed to start: container X

View file

@ -4,13 +4,16 @@ COPY server-up/requirements.txt .
RUN pip install --no-cache-dir --prefix=/inst -r requirements.txt
FROM python:3.12-slim
# 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).
ARG SU_VERSION=dev
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"
# De volgorde hieronder is met opzet: alles wat zelden verandert staat boven,
# alles wat bij elke commit verandert onderaan. Docker hergebruikt een laag
# alleen zolang niets ervóór is gewijzigd, dus één regel op de verkeerde plek
# kost bij elke build opnieuw de hele rit.
#
# Wat hier eerder misging: `ARG SU_VERSION` met een LABEL erachter stond
# bovenaan. Die waarde verandert per release, dus elke uitgave gooide álle
# 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
# 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
@ -40,9 +43,11 @@ RUN DPKG_ARCH=$(dpkg --print-architecture) \
COPY --from=build /inst /usr/local
WORKDIR /app
COPY server-up/ ./
COPY modules/ ./modules-bundled/
COPY VERSION ./VERSION
# De front-end-bestanden staan vóór de broncode: hun versies staan hier vast,
# dus deze lagen blijven staan zolang je ze niet zelf wijzigt. Stonden ze erna,
# dan haalde elke commit ze opnieuw van het CDN én werden ze bij elke push
# opnieuw geüpload.
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/fonts/materialdesignicons-webfont.woff2" -o static/fonts/materialdesignicons-webfont.woff2 \
@ -70,7 +75,6 @@ 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
# sleutel later, dan faalt de verbinding — met `no` was elke MITM onzichtbaar.
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_SECRET=/data/secret.key SU_JOBS=/data/jobs \
GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/data/known_hosts"
@ -83,3 +87,22 @@ 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.
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
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.30
0.8.31

View file

@ -76,25 +76,25 @@ services:
- MONGO_DBNAME=unifi
- MONGO_AUTHSOURCE=admin
volumes:
- ./init-mongo.sh:/init-mongo.sh:ro
# De hele stackmap en niet het losse script. Een bind-mount naar een
# 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
# image alleen bij de allereerste start schrijft.
- << 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:
- /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."
if [ ! -f /su-stack/init-mongo.sh ]; then
echo "FOUT: init-mongo.sh ontbreekt in de stackmap van deze app."
echo "Herstel het met: Instellingen wijzigen -> opslaan, dan wordt"
echo "het sjabloon opnieuw uitgeschreven."
exit 1
fi
exec bash /init-mongo.sh
exec bash /su-stack/init-mongo.sh
depends_on:
<< service_name >>-mongo:
condition: service_healthy

View file

@ -186,8 +186,22 @@ def pull(tag: str, log_fn=None) -> tuple[bool, str]:
log_fn(f"Ophalen {image}")
rc = docker._stream([docker._bin(), "pull", image], log_fn=log_fn)
if rc != 0:
return False, (f"Ophalen van {image} mislukt. Bestaat die tag in de "
"registry, en heeft deze server er toegang toe?")
# "Not found" is bijna nooit een toegangsprobleem: er staat gewoon geen
# image met die tag. De release bestaat wel — die maakt Forgejo bij de
# 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

View file

@ -415,7 +415,11 @@ def test_unifi_heeft_pi_geschikte_en_correct_geinitialiseerde_mongodb():
"service_completed_successfully"
assert reparatie["depends_on"]["unifi-mongo"]["condition"] == "service_healthy"
assert reparatie["restart"] == "no"
assert "./init-mongo.sh:/init-mongo.sh:ro" in reparatie["volumes"]
# De hele map en niet het losse bestand: een bind-mount naar een bestand
# 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"])
# De beheerdersnaam van het allereerste sjabloon. Een volume van toen kent
# geen 'root', en dan mislukte elke reparatie én elke mongodump.

59
tests/test_dockerfile.py Normal file
View file

@ -0,0 +1,59 @@
"""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,3 +117,39 @@ def test_image_zonder_schema_blijft_gelijk(env):
# 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"})
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,6 +238,9 @@ 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 "if [ ! -f /su-stack/init-mongo.sh ]" in bron
assert "Instellingen wijzigen" in bron
assert "exec bash /init-mongo.sh" 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