diff --git a/CHANGELOG.md b/CHANGELOG.md index 3341cb1..f3ff6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,23 @@ -# v0.8.31-beta — In ontwikkeling +# v0.8.31-beta — Bouwen duurt geen kwartier meer -Nog niets uitgebracht sinds v0.8.30. Wat hier onder komt te staan gaat mee in -de volgende uitgave; bij het uitbrengen wordt deze kop de releaseversie. +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 diff --git a/Dockerfile b/Dockerfile index e08cbf1..c3bc844 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" diff --git a/server-up/core/selfupdate.py b/server-up/core/selfupdate.py index 27a1ba7..dae21db 100644 --- a/server-up/core/selfupdate.py +++ b/server-up/core/selfupdate.py @@ -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 diff --git a/tests/test_dockerfile.py b/tests/test_dockerfile.py new file mode 100644 index 0000000..45784c1 --- /dev/null +++ b/tests/test_dockerfile.py @@ -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}" diff --git a/tests/test_update_adres.py b/tests/test_update_adres.py index 66bcc52..231ee5d 100644 --- a/tests/test_update_adres.py +++ b/tests/test_update_adres.py @@ -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