From 8ff5f4b1d345541194daa159c90756c7174d4795 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 26 Jul 2026 15:13:18 +0200 Subject: [PATCH] v0.5.10-beta - update-systeem met kanalen en een-klik bijwerken - Update-kanalen stable/beta in plaats van het vinkje "pre-releases meenemen". Stable ziet alleen releases, beta ook pre-releases; een release telt hoger dan zijn eigen beta (0.5.10-beta1 < 0.5.10). UPDATE_INCLUDE_PRERELEASE migreert automatisch naar het beta-kanaal. - Nieuw core/selfupdate.py: image uit de registry ophalen, SU_TAG wegschrijven en de eigen container laten hercreeren door een korte helper-container die op het nieuwe image draait (bevat de docker- en compose-CLI al). Een container kan zichzelf niet hercreeren, vandaar de helper. - Deploy-map wordt uitgelezen uit de compose-labels van de eigen container. Ontbreken die, draait de container niet vanaf een registry-image of is de docker-socket er niet, dan meldt de UI waarom bijwerken niet kan. - Vorige tag wordt onthouden; terugrolknop in de instellingen. - Update-check een uur gecached, met geforceerde check via de knop; eerder deed elke paginalading een netwerkverzoek. Laatst-gecontroleerd zichtbaar. - Registry-inloggegevens instelbaar; het token komt net als de git-tokens nooit terug via de API en leeg laten betekent ongewijzigd. - docker-compose.yml gebruikt ${SU_IMAGE:-server-up}:${SU_TAG:-latest}; zonder SU_IMAGE blijft lokaal bouwen werken zoals voorheen. - deploy-prod.yml bouwt en pusht het image in dezelfde job wanneer SU_IMAGE ingesteld is; build.yml is nu alleen handmatig. Bewust geen aparte build-workflow op dezelfde tag: bij een runner met een job tegelijk zou de deploy wachten op een build die zelf nog in de wachtrij staat. - docs/updates.md: werking, complete Forgejo-instelling (registry, tokens, variables, secrets, runners), release-procedure voor stable en beta, terugrollen en probleemoplossing. - Tests uitgebreid naar 148 (kanaallogica, cache, image-afleiding incl. registry-poortnummers, tag-validatie, .env-schrijven, tokenlek). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb --- .forgejo/workflows/build.yml | 95 +++++++--- .forgejo/workflows/deploy-prod.yml | 36 +++- .forgejo/workflows/deploy.yml | 10 +- CHANGELOG.md | 53 ++++++ VERSION | 2 +- docker-compose.yml | 6 +- docs/updates.md | 278 +++++++++++++++++++++++++++++ server-up/app.py | 86 ++++++++- server-up/core/__init__.py | 24 ++- server-up/core/selfupdate.py | 268 +++++++++++++++++++++++++++ server-up/core/updater.py | 100 +++++++++-- server-up/templates/index.html | 133 ++++++++++++-- tests/test_selfupdate.py | 156 ++++++++++++++++ tests/test_update_channels.py | 115 ++++++++++++ 14 files changed, 1297 insertions(+), 65 deletions(-) create mode 100644 docs/updates.md create mode 100644 server-up/core/selfupdate.py create mode 100644 tests/test_selfupdate.py create mode 100644 tests/test_update_channels.py diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 1b2a00a..a606b95 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1,52 +1,89 @@ -# Forgejo Actions — bouw + push image NAAR DE REGISTRY (optioneel, handmatig) +# Forgejo Actions — bouw het image en push het naar de container-registry. # -# UITGESCHAKELD voor automatische runs. Deze server bouwt zelf via -# deploy-prod.yml / deploy.yml (rsync + `docker compose build`), dus een -# registry-push is niet nodig. Stond dit op `push: tags: v*`, dan bleef de job -# hangen omdat hij een runner met label `docker` zoekt die niet bestaat — -# dat veroorzaakte de eindeloos "building" run in de Actions-tab. +# Dit image is wat de "Nu bijwerken"-knop in Server Up ophaalt. Draait bij elke +# v*-tag, zodat elke release (en pre-release) een image krijgt met dezelfde tag +# als de release zelf: v0.5.10 → server-up:0.5.10. # -# Wil je tóch naar de Forgejo container-registry publiceren: start deze workflow -# handmatig via de Actions-tab (Run workflow) en zorg dat er een runner met -# label `docker` bestaat (of pas `runs-on` aan naar je eigen runner-label). +# Vereisten (Repository → Settings): +# Variables → REGISTRY = git.example.com (host van je Forgejo) +# OWNER = bes-r (gebruiker/organisatie) +# Secrets → PACKAGE_TOKEN = # -# Vereisten (alleen voor handmatige runs): -# Settings → Variables → REGISTRY=git.example.com, OWNER=bes-r -# Settings → Secrets → PACKAGE_TOKEN= +# Zie docs/updates.md voor de complete instelling. -name: Build and push image (handmatig) +name: Build en push image (handmatig) +# Alleen handmatig. Bij een v*-tag bouwt en pusht deploy-prod.yml het image al +# in dezelfde job — zo kan er geen wachtrij ontstaan waarin de deploy op een +# build wacht die zelf nog in de rij staat. on: workflow_dispatch: + inputs: + tag: + description: "Versie om te bouwen (bv. 0.5.10). Leeg = VERSION-bestand" + required: false jobs: build: - runs-on: docker + runs-on: [self-hosted, prod] + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v4 - - name: Extract versie uit tag - id: tag - run: echo "v=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT - - - name: Login op Forgejo registry + - name: Versie en tags bepalen + id: meta run: | + set -euo pipefail + VERSION="${{ inputs.tag }}" + if [ -z "$VERSION" ]; then + VERSION="$(tr -d '[:space:]' < VERSION)" + fi + IMAGE="${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + echo "IMAGE=${IMAGE}" >> "$GITHUB_ENV" + + # Pre-release (tag met een streepje, bv. v0.5.10-beta1) krijgt géén + # 'latest'-tag: die hoort bij het stable-kanaal. + if [[ "$VERSION" == *-* ]]; then + echo "EXTRA_TAG=beta" >> "$GITHUB_ENV" + else + echo "EXTRA_TAG=latest" >> "$GITHUB_ENV" + fi + echo "Bouwen: ${IMAGE}:${VERSION}" + + - name: Tests draaien + run: | + set -euo pipefail + python3 -m venv /tmp/su-build-venv + /tmp/su-build-venv/bin/pip install -q -r server-up/requirements.txt pytest + /tmp/su-build-venv/bin/python -m pytest tests -q + + - name: Inloggen op de registry + run: | + set -euo pipefail echo "${{ secrets.PACKAGE_TOKEN }}" \ - | docker login ${{ vars.REGISTRY }} \ - -u ${{ vars.OWNER }} \ - --password-stdin + | docker login "${{ vars.REGISTRY }}" \ + -u "${{ vars.OWNER }}" --password-stdin - - name: Bouw image met versie ingebakken + - name: Bouwen run: | + set -euo pipefail docker build \ - --build-arg SU_VERSION=${{ steps.tag.outputs.v }} \ - -t ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:${{ steps.tag.outputs.v }} \ - -t ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:latest \ + --pull \ + --build-arg "SU_VERSION=${VERSION}" \ + -t "${IMAGE}:${VERSION}" \ + -t "${IMAGE}:${EXTRA_TAG}" \ . - - name: Push image + - name: Pushen run: | - docker push ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:${{ steps.tag.outputs.v }} - docker push ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:latest + set -euo pipefail + docker push "${IMAGE}:${VERSION}" + docker push "${IMAGE}:${EXTRA_TAG}" + echo "Gepusht: ${IMAGE}:${VERSION} en ${IMAGE}:${EXTRA_TAG}" + + - name: Uitloggen + if: always() + run: docker logout "${{ vars.REGISTRY }}" || true diff --git a/.forgejo/workflows/deploy-prod.yml b/.forgejo/workflows/deploy-prod.yml index 97dfad2..3115b69 100644 --- a/.forgejo/workflows/deploy-prod.yml +++ b/.forgejo/workflows/deploy-prod.yml @@ -60,13 +60,41 @@ jobs: working-directory: ${{ env.DEPLOY_DIR }} run: echo "${VERSION}" > VERSION - - name: Build image + - name: Bouwen (en pushen naar de registry) working-directory: ${{ env.DEPLOY_DIR }} run: | + set -euo pipefail + # SU_TAG vastleggen zodat compose en de "Nu bijwerken"-knop in de app + # naar hetzelfde image wijzen (zie docs/updates.md). + touch .env + sed -i '/^SU_TAG=/d' .env + echo "SU_TAG=${VERSION}" >> .env + + # Draait deze server vanaf de registry (SU_IMAGE in .env), dan bouwen + # we hier én pushen we het resultaat, zodat de in-app updateknop het + # image kan ophalen. Bouwen en pushen zitten bewust in dezelfde job: + # een aparte build-workflow zou met deze deploy om de runner strijden. + SU_IMAGE="$(grep -E '^SU_IMAGE=' .env | cut -d= -f2- || true)" docker compose build --pull - # docker compose bouwt als server-up:latest; voeg versie- en channel-tags toe - docker tag server-up:latest "server-up:${VERSION}" - docker tag server-up:latest "server-up:${CHANNEL}" + + if [ -n "$SU_IMAGE" ]; then + echo "Registry-modus: ${SU_IMAGE}:${VERSION} pushen" + echo "${{ secrets.PACKAGE_TOKEN }}" \ + | docker login "${{ vars.REGISTRY }}" -u "${{ vars.OWNER }}" --password-stdin + docker tag "${SU_IMAGE}:${VERSION}" "${SU_IMAGE}:${CHANNEL}" || true + docker push "${SU_IMAGE}:${VERSION}" + # Pre-releases krijgen geen 'latest': dat hoort bij stable. + case "$VERSION" in + *-*) docker tag "${SU_IMAGE}:${VERSION}" "${SU_IMAGE}:beta" + docker push "${SU_IMAGE}:beta" ;; + *) docker tag "${SU_IMAGE}:${VERSION}" "${SU_IMAGE}:latest" + docker push "${SU_IMAGE}:latest" ;; + esac + docker logout "${{ vars.REGISTRY }}" || true + else + echo "Lokale bouwmodus (geen SU_IMAGE ingesteld)" + docker tag "server-up:${VERSION}" "server-up:${CHANNEL}" || true + fi - name: Deploy working-directory: ${{ env.DEPLOY_DIR }} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index a4e1865..3bb6f2a 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -63,10 +63,14 @@ jobs: - name: Build image working-directory: ${{ env.DEPLOY_DIR }} run: | + set -euo pipefail + # De dev-server bouwt altijd zelf vanaf de dev-branch. SU_TAG vastleggen + # zodat compose exact het zojuist gebouwde image start. + touch .env + sed -i '/^SU_TAG=/d' .env + echo "SU_TAG=${VERSION}" >> .env docker compose build --pull - # docker compose bouwt als server-up:latest; voeg versie- en channel-tags toe - docker tag server-up:latest "server-up:${VERSION}" - docker tag server-up:latest "server-up:${CHANNEL}" + docker tag "server-up:${VERSION}" "server-up:${CHANNEL}" || true - name: Deploy working-directory: ${{ env.DEPLOY_DIR }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 783c3e3..9114399 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +# v0.5.10-beta — Update-systeem met kanalen en één-klik bijwerken + +## 🔄 Kanalen + +Server Up kent nu twee update-kanalen in plaats van een vinkje "pre-releases +meenemen": + +- **stable** — alleen echte releases (`v0.5.10`) +- **beta** — ook pre-releases (`v0.5.10-beta1`), die `release.yml` automatisch + als zodanig markeert bij een tag met een streepje + +Een release telt hoger dan zijn eigen beta (`0.5.10-beta1` < `0.5.10`), dus wie +op beta zit krijgt de definitieve versie alsnog aangeboden. De oude instelling +`UPDATE_INCLUDE_PRERELEASE` migreert automatisch naar het beta-kanaal. + +## ⬇️ Bijwerken vanuit de interface + +Nieuw: **Nu bijwerken** haalt het image uit je Forgejo container-registry en +vervangt de eigen container. + +- Omdat een container zichzelf niet kan hercreëren, doet Server Up alleen het + voorwerk (image ophalen, `SU_TAG` wegschrijven) en laat het de hercreatie over + aan een korte helper-container die op het nieuwe image draait — dat bevat de + docker- en compose-CLI al. +- De deploy-map wordt uitgelezen uit de compose-labels van de eigen container, + niet geraden. Ontbreken die labels, draait de container niet vanaf een + registry-image, of is de docker-socket er niet, dan meldt de interface + precies waaróm bijwerken niet kan in plaats van iets te proberen. +- De vorige tag wordt onthouden, met een **terugrolknop** in de instellingen. +- Voortgang van de `docker pull` loopt via het bestaande job-logvenster; daarna + pollt de browser `/healthz` tot de nieuwe versie leeft. + +## 🔧 Overig + +- Update-check wordt een uur gecached; **Controleren** forceert een verse check. + Eerder deed elke paginalading een netwerkverzoek. +- Laatst-gecontroleerd-tijdstip zichtbaar in de interface. +- Registry-inloggegevens instelbaar voor een privé registry. Het token wordt net + als de git-tokens nooit teruggegeven door de API (alleen een `has_`-vlag) en + leeg laten betekent "ongewijzigd". +- `docker-compose.yml` gebruikt `${SU_IMAGE:-server-up}:${SU_TAG:-latest}`, zodat + image en tag los instelbaar zijn. Zonder `SU_IMAGE` blijft alles werken zoals + voorheen (lokaal bouwen). +- `deploy-prod.yml` bouwt én pusht het image in dezelfde job wanneer `SU_IMAGE` + ingesteld is. Bewust niet als aparte workflow: bij één runner zou de deploy + wachten op een build die zelf nog in de wachtrij staat. +- `build.yml` is nu alleen handmatig, voor het herbouwen van een specifieke tag. +- `docs/updates.md` toegevoegd: hoe het werkt, de complete Forgejo-instelling + (registry, tokens, variables, secrets, runners), de release-procedure voor + zowel stable als beta, terugrollen en een probleemoplostabel. + +--- + # v0.5.00-beta — Authenticatie, beveiliging en eigen IP-adressen > **Let op bij het bijwerken.** Server Up heeft nu een login. Open na het diff --git a/VERSION b/VERSION index 61f0ee2..ce5c3c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.00-beta +0.5.10-beta diff --git a/docker-compose.yml b/docker-compose.yml index 6456365..8df8431 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,11 @@ services: # Bakt de versie in het image. De deploy-workflow zet SU_VERSION # (git-tag of main-); zonder waarde valt het terug op 'dev'. SU_VERSION: ${SU_VERSION:-dev} - image: server-up:latest + # Image + tag staan los, zodat bijwerken vanuit de interface werkt: die + # schrijft SU_TAG in .env en laat compose de container hercreëren. + # Wil je bijwerken via de interface, zet SU_IMAGE dan op je registry: + # SU_IMAGE=git.example.com/bes-r/server-up + image: ${SU_IMAGE:-server-up}:${SU_TAG:-latest} container_name: server-up hostname: server-up user: "0:0" diff --git a/docs/updates.md b/docs/updates.md new file mode 100644 index 0000000..f874157 --- /dev/null +++ b/docs/updates.md @@ -0,0 +1,278 @@ +# Updates en Forgejo-instelling + +Server Up controleert zelf of er een nieuwere versie is en kan zichzelf +bijwerken met één klik. Dit document beschrijft hoe dat werkt en hoe je Forgejo +er precies voor inricht. + +--- + +## Hoe het werkt + +Er zijn twee losse onderdelen: + +| Onderdeel | Wat het doet | Waar het vandaan komt | +|-----------|--------------|------------------------| +| **Detectie** | "Er is een v0.5.10" | Forgejo **Releases-API** | +| **Uitvoeren** | Het nieuwe image ophalen en de container vervangen | Forgejo **container-registry** | + +Je hebt ze allebei nodig voor de updateknop. Alleen detectie werkt ook prima — +dan zie je een melding en werk je bij met een git-tag, zoals voorheen. + +### Kanalen + +| Kanaal | Ziet | Voorbeeld | +|--------|------|-----------| +| `stable` | Alleen echte releases | `v0.5.10` | +| `beta` | Ook pre-releases | `v0.5.10-beta1` én `v0.5.10` | + +Een tag met een streepje erin (`v0.5.10-beta1`) wordt door `release.yml` +automatisch als pre-release gemarkeerd. Semver bepaalt de volgorde, en een +release telt hoger dan zijn eigen beta: `0.5.10-beta1` < `0.5.10`. Zit je op +beta en verschijnt de definitieve `v0.5.10`, dan krijg je die dus als update +aangeboden. + +### Wat er gebeurt als je op "Nu bijwerken" klikt + +``` +1. docker pull git.example.com/bes-r/server-up:0.5.10 +2. SU_TAG=0.5.10 wegschrijven in het .env van de deploy-map +3. helper-container starten: sleep 5 && docker compose up -d server-up +4. Server Up wordt vervangen; de browser pollt /healthz tot de nieuwe versie leeft +``` + +Stap 3 is nodig omdat een container zichzelf niet kan hercreëren: het commando +zou halverwege zijn eigen proces afbreken. De helper draait op het zojuist +gehaalde image — dat bevat de docker- en compose-CLI al — en ruimt zichzelf op. + +De deploy-map wordt niet geraden maar uitgelezen uit de compose-labels op de +eigen container (`com.docker.compose.project.working_dir`). Draait Server Up niet +via compose, dan meldt de interface dat bijwerken niet kan in plaats van iets te +proberen. + +--- + +## Deel 1 — Forgejo inrichten + +### 1.1 Container-registry aanzetten + +De registry zit in Forgejo ingebouwd, maar staat niet altijd aan. Controleer in +`app.ini` op je Forgejo-server: + +```ini +[packages] +ENABLED = true +``` + +Herstart Forgejo na een wijziging. Ga daarna naar je profiel → **Packages**; als +die pagina bestaat, werkt de registry. + +> **Draai je Forgejo op een poort (bv. `:3000`) zonder TLS?** Dan moet Docker die +> registry als "insecure" kennen. Zet op elke server die images ophaalt in +> `/etc/docker/daemon.json`: +> +> ```json +> { "insecure-registries": ["10.0.20.22:3000"] } +> ``` +> +> en `systemctl restart docker`. Met HTTPS en een geldig certificaat is dit niet +> nodig — dat heeft de voorkeur. + +### 1.2 Token aanmaken voor de registry + +**Instellingen → Applicaties → Nieuw token genereren** + +| Veld | Waarde | +|------|--------| +| Naam | `server-up-packages` | +| Rechten | `package: Read and Write` | + +Kopieer het token meteen — je ziet het maar één keer. Je hebt het twee keer +nodig: als repository-secret (voor de build) en in Server Up zelf (voor het +ophalen). + +Wil je scheiden: maak een tweede token met alleen `package: Read` voor de +servers die enkel ophalen. Dat is netter, maar niet verplicht. + +### 1.3 Repository-variabelen en -secrets + +Ga naar de repository → **Settings → Actions**. + +**Variables** (niet geheim, zichtbaar in logs): + +| Naam | Waarde | Toelichting | +|------|--------|-------------| +| `REGISTRY` | `10.0.20.22:3000` | Host van je Forgejo, zonder `http://` | +| `OWNER` | `bes-r` | Je gebruikersnaam of organisatie | + +**Secrets** (versleuteld, gemaskeerd in logs): + +| Naam | Waarde | +|------|--------| +| `PACKAGE_TOKEN` | het token uit 1.2 | +| `RELEASE_TOKEN` | *(optioneel)* token met `repository: Read and Write`, als het automatische `GITHUB_TOKEN` niet volstaat voor `release.yml` | + +### 1.4 Runners controleren + +De workflows verwachten deze labels: + +| Workflow | Draait op | Trigger | +|----------|-----------|---------| +| `deploy.yml` | `[self-hosted, dev]` | push naar `dev` | +| `deploy-prod.yml` | `[self-hosted, prod]` | push naar `main` + `v*`-tags | +| `release.yml` | `[self-hosted, prod]` | `v*`-tags | +| `build.yml` | `[self-hosted, prod]` | alleen handmatig | + +Controleer je labels met: + +```bash +# op de server waar act_runner draait +grep -A3 'labels:' /etc/act_runner/config.yaml +``` + +Staat er iets anders, pas dan `runs-on:` in de workflows aan óf hernoem de +labels van de runner. De runner heeft toegang tot de docker-socket nodig. + +> **Let op de volgorde.** `deploy-prod.yml` bouwt én pusht het image in dezelfde +> job. Dat is bewust: een aparte build-workflow zou bij één runner met de deploy +> om dezelfde plek in de wachtrij strijden, waarbij de deploy wacht op een build +> die zelf nog in de rij staat. `build.yml` is er alleen voor handmatig +> herbouwen. + +--- + +## Deel 2 — De server instellen + +Op de server waar Server Up draait, in de deploy-map (`/opt/server-up`): + +```bash +# .env +SU_IMAGE=10.0.20.22:3000/bes-r/server-up +SU_TAG=0.5.10 +BIND=127.0.0.1 +PORT=5000 +``` + +`SU_IMAGE` is het schakelaartje: staat het er, dan draait deze server vanaf de +registry en werkt de updateknop. Staat het er niet, dan bouwt de server lokaal +zoals voorheen en meldt de interface netjes waarom bijwerken niet kan. + +Eerste keer overstappen van lokaal bouwen naar de registry: + +```bash +cd /opt/server-up +docker login 10.0.20.22:3000 -u bes-r # token uit 1.2 als wachtwoord +docker pull 10.0.20.22:3000/bes-r/server-up:0.5.10 +docker compose up -d +docker compose ps # controleer de nieuwe image-naam +``` + +## Deel 3 — Server Up instellen + +**Instellingen → Updates**: + +| Veld | Waarde | +|------|--------| +| Kanaal | `Stable` of `Beta` | +| Releases-API URL | `http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases` | +| Image | `10.0.20.22:3000/bes-r/server-up` | +| Registry-gebruiker | `bes-r` *(alleen bij een privé repo)* | +| Registry-token | het token uit 1.2 *(idem)* | + +Het registry-token wordt opgeslagen in `config.json` (0600) en komt nooit terug +via de API — je ziet alleen of het ingesteld is. Leeg laten bij het opslaan +betekent "niet wijzigen". + +Klik op **Controleren**. Staat er een versie, dan werkt de detectie. + +--- + +## Een versie uitbrengen + +```bash +# 1. Versienummer bijwerken +echo "0.5.10" > VERSION + +# 2. Changelog-sectie schrijven (release.yml haalt de notes hieruit) +$EDITOR CHANGELOG.md # begin met: # v0.5.10 — Korte titel + +# 3. Vastleggen en taggen +git add VERSION CHANGELOG.md +git commit -m "v0.5.10 - korte omschrijving" +git push origin dev + +# 4. Via een pull request naar main, dan pas taggen +git checkout main && git pull +git tag v0.5.10 +git push origin v0.5.10 +``` + +Die tag zet drie dingen in gang: + +1. **`release.yml`** maakt de Forgejo-release met notes uit `CHANGELOG.md` plus + een commitoverzicht sinds de vorige tag. +2. **`deploy-prod.yml`** draait de tests, bouwt het image, pusht het als + `:0.5.10` én `:latest`, en herstart de prod-server. +3. Elke Server Up-installatie ziet bij de volgende check de nieuwe versie. + +### Een beta uitbrengen + +Precies hetzelfde, met een streepje in de tag: + +```bash +echo "0.5.10-beta1" > VERSION +git commit -am "v0.5.10-beta1" +git tag v0.5.10-beta1 +git push origin v0.5.10-beta1 +``` + +`release.yml` markeert dit automatisch als pre-release, en het image krijgt de +tags `:0.5.10-beta1` en `:beta` (géén `:latest`). Alleen installaties op het +beta-kanaal zien hem. + +Volgens de projectafspraken toont de dev-branch altijd `beta` in het +versienummer; die tags horen dus bij wat je vanuit dev uitbrengt. + +--- + +## Terugrollen + +Server Up onthoudt bij elke update de vorige tag. Onder **Instellingen → +Updates** verschijnt dan een knop "Terug naar deze versie", die precies hetzelfde +doet met het oude image. + +Start de nieuwe versie helemaal niet — dan is er ook geen interface om op te +klikken — dan doe je het met de hand: + +```bash +cd /opt/server-up +sed -i 's/^SU_TAG=.*/SU_TAG=0.5.00/' .env +docker compose up -d +``` + +--- + +## Problemen oplossen + +| Symptoom | Oorzaak en oplossing | +|----------|----------------------| +| Knop staat uit, "komt niet uit een registry" | `SU_IMAGE` ontbreekt in `.env`, of de container draait nog op een lokaal gebouwd image. Zie deel 2. | +| Knop staat uit, "geen compose-labels" | De container is met `docker run` gestart in plaats van met compose. Start hem via `docker compose up -d`. | +| Knop staat uit, "container niet te vinden" | De docker-socket is niet gemount, of de containernaam wijkt af. Zet `SU_CONTAINER` op de juiste naam. | +| `docker pull` faalt met "unauthorized" | Registry-gebruiker/token niet ingesteld in Server Up, of het token mist `package: Read`. | +| `docker pull` faalt met "http: server gave HTTP response to HTTPS client" | Insecure registry niet geconfigureerd; zie 1.1. | +| "Geen releases gevonden in kanaal 'stable'" | Er zijn alleen pre-releases. Zet het kanaal op Beta of breng een release zonder streepje uit. | +| Update-check geeft een netwerkfout | De Releases-URL klopt niet, of Server Up kan Forgejo niet bereiken. Test met `curl` vanuit de container. | +| Interface komt na bijwerken niet terug | Nieuwe image start niet. Kijk met `docker compose logs server-up` en rol handmatig terug (zie boven). | +| Nieuwe versie verschijnt niet | De check cachet een uur. Klik op **Controleren** om te forceren. | + +### Handmatig controleren of de registry werkt + +```bash +# Is het image gepusht? +curl -s -u bes-r: \ + http://10.0.20.22:3000/v2/bes-r/server-up/tags/list + +# Geeft de Releases-API iets terug? +curl -s http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases \ + | python3 -m json.tool | head -30 +``` diff --git a/server-up/app.py b/server-up/app.py index 6418405..544be23 100644 --- a/server-up/app.py +++ b/server-up/app.py @@ -15,7 +15,7 @@ from flask import Flask, Response, jsonify, render_template, request, session import core as cfg from core import audit, jobs, i18n from core import auth, docker, git, boilerplates, updater -from core import compose_transform, networks +from core import compose_transform, networks, selfupdate from core.modules import Module, CORE, discover app = Flask(__name__, static_folder="static", template_folder="templates") @@ -336,7 +336,81 @@ def api_version(): @app.route("/api/update") def api_update(): - return jsonify(updater.check(VERSION)) + """Status van de update-check, aangevuld met of bijwerken mogelijk is.""" + force = request.args.get("force") in ("1", "true", "yes") + res = updater.check(VERSION, force=force) + st = selfupdate.status() + res.update({ + "can_apply": st["can_apply"], + "apply_reason": st["reason"], + "image": st["image"], + "previous_tag": st["previous_tag"], + "channels": list(updater.CHANNELS), + }) + return jsonify(res) + + +@app.route("/api/update/apply", methods=["POST"]) +def api_update_apply(): + """Werk Server Up bij naar een tag uit de releases-API. + + Draait als job zodat de UI de voortgang van de `docker pull` kan volgen. + Kort na afloop verdwijnt deze container; de front-end pollt daarna /healthz. + """ + d = request.json or {} + tag = (d.get("tag") or "").strip() + if not tag: + info = updater.check(VERSION) + tag = info.get("latest_tag") or "" + if not tag: + return jsonify(ok=False, msg="Geen versie opgegeven en geen release gevonden"), 400 + st = selfupdate.status() + if not st["can_apply"]: + return jsonify(ok=False, msg=st["reason"]), 400 + + jid, q = jobs.create(f"update:{tag}") + gebruiker = auth.current_user() or "" + + def _run(jid): + qq = jobs.get_queue(jid) + try: + jobs.log(qq, "section", f"Bijwerken naar {tag}") + ok, msg = selfupdate.apply(tag, log_fn=lambda m: jobs.log(qq, "dim", m)) + jobs.log(qq, "ok" if ok else "error", msg) + audit.log("update", "apply", "ok" if ok else "error", ref=tag, + detail=gebruiker) + jobs.finish(jid, "done" if ok else "error") + except Exception as e: + jobs.log(qq, "error", str(e)) + jobs.finish(jid, "error") + jobs.done(qq) + + jobs.run(_run, jid) + updater.invalidate() + return jsonify(ok=True, job_id=jid, tag=tag) + + +@app.route("/api/update/rollback", methods=["POST"]) +def api_update_rollback(): + st = selfupdate.status() + if not st["can_apply"]: + return jsonify(ok=False, msg=st["reason"]), 400 + if not st.get("previous_tag"): + return jsonify(ok=False, msg="Geen vorige versie bekend"), 400 + + jid, q = jobs.create("update:rollback") + + def _run(jid): + qq = jobs.get_queue(jid) + ok, msg = selfupdate.rollback(log_fn=lambda m: jobs.log(qq, "dim", m)) + jobs.log(qq, "ok" if ok else "error", msg) + audit.log("update", "rollback", "ok" if ok else "error", + ref=st.get("previous_tag", ""), ip=request.remote_addr) + jobs.finish(jid, "done" if ok else "error") + jobs.done(qq) + + jobs.run(_run, jid) + return jsonify(ok=True, job_id=jid, tag=st["previous_tag"]) @app.route("/api/docker/images") @@ -1476,6 +1550,14 @@ def api_settings_put(): blocked = _SETTINGS_HIDE | cfg.SECRET_KEYS updates = {k: v for k, v in d.items() if k not in blocked} current = cfg.load() + # Write-only credentials: leeg laten staan betekent "niet wijzigen", want de + # UI kent de huidige waarde niet (zie cfg.redact). + for key in cfg.WRITE_ONLY_KEYS: + updates.pop(f"has_{key.lower()}", None) + if key in updates and not str(updates[key] or "").strip(): + updates.pop(key) + if (updates.get("UPDATE_CHANNEL") or "stable") not in updater.CHANNELS: + return jsonify(ok=False, msg="Onbekend update-kanaal"), 400 for key in ("APP_REPOS", "MODULE_REPOS"): if key not in updates: continue diff --git a/server-up/core/__init__.py b/server-up/core/__init__.py index 40009bf..76d16e0 100644 --- a/server-up/core/__init__.py +++ b/server-up/core/__init__.py @@ -36,7 +36,18 @@ DEFAULTS = { # Leeg = uitgeschakeld. Voorbeeld (Forgejo): # http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases "UPDATE_API_URL": os.environ.get("SU_UPDATE_API", ""), - "UPDATE_INCLUDE_PRERELEASE": False, + "UPDATE_INCLUDE_PRERELEASE": False, # verouderd, zie UPDATE_CHANNEL + # Kanaal: "stable" (alleen releases) of "beta" (ook pre-releases). + "UPDATE_CHANNEL": os.environ.get("SU_UPDATE_CHANNEL", "stable"), + # Image waarvandaan bijgewerkt wordt, zonder tag. Leeg = afleiden uit de + # draaiende container. Bv. git.example.com/bes-r/server-up + "UPDATE_IMAGE": os.environ.get("SU_UPDATE_IMAGE", ""), + # Alleen nodig bij een privé registry. + "UPDATE_REGISTRY_USER": "", + "UPDATE_REGISTRY_TOKEN": "", + # Administratie van de laatste update (voor terugrollen). + "UPDATE_PREVIOUS_TAG": "", + "UPDATE_LAST_APPLIED": {}, # Authenticatie — beheerd via core.auth; hier alleen als placeholder zodat # load() de sleutel kent. Wordt nooit via /api/settings teruggegeven. "AUTH": {}, @@ -47,9 +58,14 @@ DEFAULTS = { "NETWORKS": [], } -# Sleutels die nooit naar de client mogen: geheimen of interne administratie. +# Sleutels die de client noch mag lezen noch mag schrijven; AUTH bevat +# wachtwoordhashes en loopt uitsluitend via /api/auth/*. SECRET_KEYS = frozenset({"AUTH"}) +# Credentials die je wél mag instellen maar nooit terugkrijgt. Bij het opslaan +# betekent een lege waarde "ongewijzigd" — net als bij de git-tokens. +WRITE_ONLY_KEYS = frozenset({"UPDATE_REGISTRY_TOKEN"}) + _path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json")) _lock = threading.RLock() @@ -118,6 +134,10 @@ def redact(config: dict) -> dict: dát er een token is zonder de waarde prijs te geven. """ out = {k: v for k, v in config.items() if k not in SECRET_KEYS} + for key in WRITE_ONLY_KEYS: + if key in out: + out[f"has_{key.lower()}"] = bool(str(out[key] or "").strip()) + out[key] = "" for key in ("APP_REPOS", "MODULE_REPOS"): repos = out.get(key) if not isinstance(repos, list): diff --git a/server-up/core/selfupdate.py b/server-up/core/selfupdate.py new file mode 100644 index 0000000..d515550 --- /dev/null +++ b/server-up/core/selfupdate.py @@ -0,0 +1,268 @@ +"""Zelf-update: Server Up vervangt zijn eigen container door een nieuwer image. + +Het lastige aan jezelf bijwerken is dat het proces dat `docker compose up -d` +uitvoert, halverwege door datzelfde commando wordt gestopt. Daarom doet Server +Up alleen het voorwerk (image ophalen, tag wegschrijven) en laat het de +daadwerkelijke hercreatie over aan een korte **helper-container**: + + 1. `docker pull /:` ← nieuw image binnenhalen + 2. `SU_TAG=` in het .env van de deploy-map schrijven + 3. helper starten: sleep 5 && docker compose up -d server-up + 4. helper stopt zichzelf (`--rm`) + +De helper draait op het zojuist gepulde image: dat bevat de docker- en +compose-CLI al (zie Dockerfile) en is per definitie aanwezig, dus er is geen +extra image nodig. + +De deploy-map wordt niet geraden maar uitgelezen uit de compose-labels die +Docker op de eigen container heeft gezet. +""" +from __future__ import annotations + +import json +import os +import re +import shlex +import time +from pathlib import Path + +import core as cfg +from core import docker + +# Labels die `docker compose` op elke container zet. +_LBL_PROJECT = "com.docker.compose.project" +_LBL_WORKDIR = "com.docker.compose.project.working_dir" +_LBL_CONFIGS = "com.docker.compose.project.config_files" +_LBL_SERVICE = "com.docker.compose.service" + +_TAG_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$") +_IMAGE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*(:[0-9]{1,5})?(/[A-Za-z0-9._/-]+)*$") + + +class UpdateError(Exception): + pass + + +# ── Zelfinspectie ──────────────────────────────────────────────────────────── + +def container_name() -> str: + return os.environ.get("HOSTNAME") or os.environ.get("SU_CONTAINER", "server-up") + + +def inspect_self() -> dict: + """Gegevens over de eigen container: image, compose-project en deploy-map.""" + r = docker._run([docker._bin(), "inspect", container_name()], timeout=15) + if r.returncode != 0: + return {} + try: + data = json.loads(r.stdout) + except Exception: + return {} + if not data: + return {} + c = data[0] + labels = (c.get("Config") or {}).get("Labels") or {} + return { + "name": (c.get("Name") or "").lstrip("/"), + "image": (c.get("Config") or {}).get("Image", ""), + "image_id": c.get("Image", ""), + "project": labels.get(_LBL_PROJECT, ""), + "working_dir": labels.get(_LBL_WORKDIR, ""), + "config_files": labels.get(_LBL_CONFIGS, ""), + "service": labels.get(_LBL_SERVICE, "") or "server-up", + } + + +def image_repo(info: dict | None = None) -> str: + """Het image zonder tag, bv. `git.example.com/bes-r/server-up`. + + Voorkeur: expliciet ingesteld via UPDATE_IMAGE. Anders afgeleid uit het + image waarmee deze container draait. + """ + expliciet = (cfg.load().get("UPDATE_IMAGE") or "").strip() + if expliciet: + return expliciet.rsplit(":", 1)[0] if _has_tag(expliciet) else expliciet + img = (info or inspect_self()).get("image", "") + if not img: + return "" + return img.rsplit(":", 1)[0] if _has_tag(img) else img + + +def _has_tag(image: str) -> bool: + """`host:5000/repo` heeft geen tag, `repo:1.2` wel — kijk ná de laatste `/`.""" + laatste = image.rsplit("/", 1)[-1] + return ":" in laatste + + +def status() -> dict: + """Kan deze installatie zichzelf bijwerken, en zo niet: waarom niet?""" + info = inspect_self() + repo = image_repo(info) + res = {"can_apply": False, "reason": "", "image": repo, + "current_image": info.get("image", ""), + "working_dir": info.get("working_dir", ""), + "service": info.get("service", ""), + "previous_tag": (cfg.load().get("UPDATE_PREVIOUS_TAG") or "")} + if not info: + res["reason"] = ("De eigen container is niet te vinden via de " + "Docker-socket. Draait Server Up wel in Docker?") + return res + if not info.get("working_dir"): + res["reason"] = ("Deze container is niet met docker compose gestart " + "(geen compose-labels), dus bijwerken vanuit de " + "interface kan niet.") + return res + if not repo: + res["reason"] = "Geen image bekend om bij te werken." + return res + if "/" not in repo: + res["reason"] = (f"Het image '{repo}' komt niet uit een registry. Stel " + "UPDATE_IMAGE in op bv. git.example.com/bes-r/server-up " + "en draai vanaf een registry-image.") + return res + res["can_apply"] = True + return res + + +# ── Registry ───────────────────────────────────────────────────────────────── + +def _login(log_fn=None) -> tuple[bool, str]: + """Optionele registry-login voor een privé Forgejo-registry.""" + c = cfg.load() + user = (c.get("UPDATE_REGISTRY_USER") or "").strip() + token = (c.get("UPDATE_REGISTRY_TOKEN") or "").strip() + if not user or not token: + return True, "" # publieke registry of al ingelogd + registry = image_repo().split("/")[0] + if log_fn: + log_fn(f"Inloggen op {registry} als {user}…") + r = docker._run([docker._bin(), "login", registry, "-u", user, + "--password-stdin"], input=token, timeout=30) + if r.returncode != 0: + # Het token mag nooit in een logregel belanden. + melding = (r.stderr or r.stdout or "").replace(token, "***").strip() + return False, f"Registry-login mislukt: {melding[:200]}" + return True, "" + + +def pull(tag: str, log_fn=None) -> tuple[bool, str]: + """Haal `:` binnen. Retourneert (ok, image of foutmelding).""" + if not _TAG_RE.match(tag or ""): + return False, f"Ongeldige tag: {tag!r}" + repo = image_repo() + if not repo or not _IMAGE_RE.match(repo): + return False, f"Ongeldig image: {repo!r}" + ok, err = _login(log_fn) + if not ok: + return False, err + image = f"{repo}:{tag}" + if log_fn: + 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?") + return True, image + + +# ── Toepassen ──────────────────────────────────────────────────────────────── + +def _write_tag(working_dir: str, tag: str, log_fn=None): + """Zet SU_TAG in het .env van de deploy-map, zodat compose het nieuwe image + gebruikt en de wijziging een herstart overleeft.""" + env_file = Path(working_dir) / ".env" + regels: list[str] = [] + if env_file.exists(): + regels = env_file.read_text(encoding="utf-8").splitlines() + nieuw, gezet = [], False + for r in regels: + if r.strip().startswith("SU_TAG="): + nieuw.append(f"SU_TAG={tag}") + gezet = True + else: + nieuw.append(r) + if not gezet: + nieuw.append(f"SU_TAG={tag}") + env_file.write_text("\n".join(nieuw) + "\n", encoding="utf-8") + if log_fn: + log_fn(f"SU_TAG={tag} vastgelegd in {env_file}") + + +def apply(tag: str, log_fn=None) -> tuple[bool, str]: + """Werk Server Up bij naar `tag`. + + Bij succes keert deze functie terug terwijl de helper al gestart is; enkele + seconden later verdwijnt dit proces omdat de container hercreëerd wordt. + """ + st = status() + if not st["can_apply"]: + return False, st["reason"] + + info = inspect_self() + werkmap = info["working_dir"] + service = info["service"] + + ok, image_of_fout = pull(tag, log_fn) + if not ok: + return False, image_of_fout + image = image_of_fout + + # Onthoud waar we vandaan komen, zodat terugrollen mogelijk blijft als het + # nieuwe image niet start. + huidig = info.get("image", "") + vorige_tag = huidig.rsplit(":", 1)[-1] if _has_tag(huidig) else "latest" + cfg.patch({"UPDATE_PREVIOUS_TAG": vorige_tag, + "UPDATE_LAST_APPLIED": {"tag": tag, "ts": time.time(), + "from": vorige_tag}}) + + try: + _write_tag(werkmap, tag, log_fn) + except Exception as e: + return False, f"Kan {werkmap}/.env niet schrijven: {e}" + + if log_fn: + log_fn(f"Vorige versie: {vorige_tag} (terugrollen kan vanuit Instellingen)") + log_fn("Helper starten die de container hercreëert…") + + ok, melding = _spawn_helper(image, werkmap, service, log_fn) + if not ok: + return False, melding + return True, (f"Bijwerken naar {tag} gestart. Server Up herstart nu; " + "de interface komt over enkele seconden terug.") + + +def _spawn_helper(image: str, working_dir: str, service: str, + log_fn=None) -> tuple[bool, str]: + """Start een korte container die `docker compose up -d ` uitvoert. + + Draait op hetzelfde (nieuwe) image, dat de docker- en compose-CLI al bevat. + `sleep` geeft dit proces de tijd om zijn HTTP-antwoord af te maken voordat + het onder zijn eigen voeten wordt weggehaald. + """ + script = (f"sleep 5; " + f"docker compose up -d --remove-orphans {shlex.quote(service)}") + cmd = [docker._bin(), "run", "--rm", "--detach", + "--name", "server-up-updater", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", f"{working_dir}:{working_dir}", + "-w", working_dir, + "--entrypoint", "sh", + image, "-c", script] + # Een blijven hangen helper van een vorige poging zou de naam bezet houden. + docker._run([docker._bin(), "rm", "-f", "server-up-updater"], timeout=15) + r = docker._run(cmd, timeout=30) + if r.returncode != 0: + return False, f"Helper starten mislukt: {(r.stderr or r.stdout).strip()[:300]}" + if log_fn: + log_fn("Helper gestart — tot zo.") + return True, "ok" + + +def rollback(log_fn=None) -> tuple[bool, str]: + """Ga terug naar de tag die vóór de laatste update draaide.""" + vorige = (cfg.load().get("UPDATE_PREVIOUS_TAG") or "").strip() + if not vorige: + return False, "Geen vorige versie bekend om naar terug te rollen." + if log_fn: + log_fn(f"Terugrollen naar {vorige}…") + return apply(vorige, log_fn) diff --git a/server-up/core/updater.py b/server-up/core/updater.py index 35f195f..4ac6c93 100644 --- a/server-up/core/updater.py +++ b/server-up/core/updater.py @@ -1,13 +1,37 @@ -"""Update-check tegen een Forgejo/GitHub Releases-API (stdlib only).""" +"""Update-check tegen een Forgejo/GitHub Releases-API (stdlib only). + +Twee kanalen: + +* **stable** — alleen echte releases (`v0.5.10`) +* **beta** — ook pre-releases (`v0.5.10-beta1`), zoals `release.yml` die + markeert voor tags met een streepje erin + +Een beta-gebruiker ziet dus zowel betas als stabiele releases; wie op stable zit +ziet alleen releases. Schakel je van beta terug naar stable terwijl je een beta +draait, dan wordt de eerstvolgende stabiele release aangeboden zodra die hoger +is (`0.5.10-beta1` < `0.5.10`). +""" from __future__ import annotations + import json import re +import threading +import time import urllib.request import core as cfg _SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+.](.+))?$") +CHANNELS = ("stable", "beta") + +# Resultaat van de laatste check, zodat niet elke paginalading een +# netwerkverzoek doet. De UI kan met force=True alsnog verversen. +_CACHE: dict = {} +_CACHE_TS: float = 0.0 +_CACHE_TTL = 3600 +_LOCK = threading.Lock() + def _pre_key(pre: str): parts = [p for p in re.split(r"[.+-]", pre) if p] if pre else [] @@ -26,30 +50,65 @@ def _parse(v: str): 1 if not pre else 0, _pre_key(pre)) -def check(current: str) -> dict: +def channel() -> str: + """Het ingestelde kanaal, met migratie vanaf de oude boolean.""" + c = cfg.load() + ch = (c.get("UPDATE_CHANNEL") or "").strip().lower() + if ch in CHANNELS: + return ch + # Oude instelling: UPDATE_INCLUDE_PRERELEASE=true betekende 'beta'. + if c.get("UPDATE_INCLUDE_PRERELEASE"): + return "beta" + return "stable" + + +def is_prerelease(version: str) -> bool: + t = _parse(version) + return bool(t and t[3] == 0) + + +def _releases(url: str) -> list[dict]: + req = urllib.request.Request( + url, headers={"Accept": "application/json", "User-Agent": "server-up"}) + with urllib.request.urlopen(req, timeout=8) as r: + data = json.loads(r.read().decode("utf-8")) + return data if isinstance(data, list) else [data] + + +def check(current: str, force: bool = False) -> dict: + """Zoek de nieuwste release in het ingestelde kanaal. + + Het resultaat wordt een uur gecached; `force=True` omzeilt de cache. + """ + global _CACHE, _CACHE_TS + with _LOCK: + if not force and _CACHE and (time.time() - _CACHE_TS) < _CACHE_TTL: + if _CACHE.get("current") == current: + return dict(_CACHE) + c = cfg.load() url = (c.get("UPDATE_API_URL") or "").strip() - include_pre = bool(c.get("UPDATE_INCLUDE_PRERELEASE")) - res = {"enabled": bool(url), "current": current, "latest": None, - "update_available": False, "url": "", "notes": "", - "prerelease": False, "error": ""} + ch = channel() + res = {"enabled": bool(url), "current": current, "channel": ch, + "current_is_prerelease": is_prerelease(current), + "latest": None, "latest_tag": "", "update_available": False, + "url": "", "notes": "", "prerelease": False, "error": "", + "checked_at": time.time()} if not url: return res + try: - req = urllib.request.Request( - url, headers={"Accept": "application/json", "User-Agent": "server-up"}) - with urllib.request.urlopen(req, timeout=8) as r: - data = json.loads(r.read().decode("utf-8")) + rels = _releases(url) except Exception as e: res["error"] = f"{type(e).__name__}: {e}" return res - rels = data if isinstance(data, list) else [data] best, best_t = None, None for rel in rels: if not isinstance(rel, dict) or rel.get("draft"): continue - if rel.get("prerelease") and not include_pre: + # Op stable tellen pre-releases niet mee; op beta wél. + if rel.get("prerelease") and ch != "beta": continue t = _parse(rel.get("tag_name") or rel.get("name") or "") if not t: @@ -57,13 +116,28 @@ def check(current: str) -> dict: if best_t is None or t > best_t: best, best_t = rel, t if not best: + res["error"] = f"Geen releases gevonden in kanaal '{ch}'" return res - res["latest"] = (best.get("tag_name") or best.get("name") or "").lstrip("v") + tag = (best.get("tag_name") or best.get("name") or "") + res["latest_tag"] = tag + res["latest"] = tag.lstrip("v") res["url"] = best.get("html_url") or best.get("url") or "" res["notes"] = best.get("body") or "" res["prerelease"] = bool(best.get("prerelease")) cur_t = _parse(current) if cur_t and best_t and best_t > cur_t: res["update_available"] = True + + with _LOCK: + _CACHE = dict(res) + _CACHE_TS = time.time() return res + + +def invalidate(): + """Gooi de cache weg (na een instellingswijziging of na een update).""" + global _CACHE, _CACHE_TS + with _LOCK: + _CACHE = {} + _CACHE_TS = 0.0 diff --git a/server-up/templates/index.html b/server-up/templates/index.html index 364fdfc..7d09156 100644 --- a/server-up/templates/index.html +++ b/server-up/templates/index.html @@ -550,20 +550,74 @@ tailwind.config = {

               
             
-            
+            
+ + Bekijk release +
+ +

+ + + Bijwerken kan wel via een git-tag; zie docs/updates.md. +

+

+ Laatst gecontroleerd: +

+
+
+ +
+ + +
+

+ Alleen stabiele releases. + Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar. +

+
- +

Forgejo/GitHub Releases-API. Leeg = uit.

- +
+ + +

+ Zonder tag. Leeg = afleiden uit de draaiende container. +

+
+
+
+ + +
+
+ + +

Leeg laten = ongewijzigd.

+
+
+
+ +
+ Vorige versie: + + +
+
@@ -1043,7 +1097,10 @@ function app() { settings: {}, languages: [], modules: [], - update: { enabled:false, update_available:false, current:'', latest:'', url:'', notes:'', prerelease:false }, + update: { enabled:false, update_available:false, current:'', latest:'', latest_tag:'', + url:'', notes:'', prerelease:false, channel:'stable', can_apply:false, + apply_reason:'', previous_tag:'', checked_at:0 }, + updating: false, T: {}, job: { id: null, lines: [], status: 'idle', timer: null }, @@ -1535,7 +1592,63 @@ function app() { async loadModules() { const d = await this.rpc('/api/modules'); this.modules = Array.isArray(d) ? d : []; }, - async loadUpdate() { const d = await this.rpc('/api/update'); if (d && typeof d === 'object') this.update = d; }, + async loadUpdate(force) { + const d = await this.rpc('/api/update' + (force ? '?force=1' : '')); + if (d && typeof d === 'object') this.update = d; + }, + updChannel() { return this.settings.UPDATE_CHANNEL || this.update.channel || 'stable'; }, + setChannel(ch) { + this.settings.UPDATE_CHANNEL = ch; + this.autoSave(); + setTimeout(() => this.loadUpdate(true), 600); + }, + fmtTime(ts) { + if (!ts) return ''; + return new Date(ts * 1000).toLocaleString(); + }, + + // Bijwerken: de container vervangt zichzelf, dus na het startsein is deze + // pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft. + async applyUpdate() { + const naar = this.update.latest_tag || this.update.latest; + if (!confirm('Server Up bijwerken naar ' + naar + '?\n\n' + + 'De container wordt vervangen en is enkele seconden niet bereikbaar.')) return; + this.updating = true; + const d = await this.rpc('/api/update/apply', { tag: naar }); + if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; } + this.termOpen = true; + this.pollJob(d.job_id, () => this.waitForRestart()); + }, + async rollbackUpdate() { + if (!confirm('Terugrollen naar ' + this.update.previous_tag + '?')) return; + this.updating = true; + const d = await this.rpc('/api/update/rollback', {}); + if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; } + this.termOpen = true; + this.pollJob(d.job_id, () => this.waitForRestart()); + }, + async waitForRestart() { + this.toast('Server Up herstart…', 'in'); + // Even wachten tot de oude container écht weg is, anders zien we hem nog. + await new Promise(r => setTimeout(r, 8000)); + for (let i = 0; i < 60; i++) { + try { + const r = await fetch('/healthz', { cache: 'no-store' }); + if (r.ok) { + const d = await r.json(); + if (d.version && d.version !== this.update.current) { + this.updating = false; + this.toast('Bijgewerkt naar v' + d.version, 'ok'); + setTimeout(() => location.reload(), 1200); + return; + } + } + } catch (e) { /* container is nog aan het herstarten */ } + await new Promise(r => setTimeout(r, 2000)); + } + this.updating = false; + this.toast('Herstart duurt lang — ververs de pagina zelf', 'wr'); + }, async checkWizard() { const d = await this.rpc('/api/wizard'); diff --git a/tests/test_selfupdate.py b/tests/test_selfupdate.py new file mode 100644 index 0000000..0ec85bd --- /dev/null +++ b/tests/test_selfupdate.py @@ -0,0 +1,156 @@ +"""Zelf-update: image-afleiding, veiligheidscontroles en de tag-administratie.""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up")) + +from conftest import login + + +@pytest.fixture +def su(env): + from core import selfupdate + return selfupdate + + +def _fake_inspect(su, monkeypatch, **overrides): + info = {"name": "server-up", "image": "git.example.com/bes-r/server-up:0.5.00", + "image_id": "sha256:abc", "project": "server-up", + "working_dir": "/opt/server-up", + "config_files": "/opt/server-up/docker-compose.yml", + "service": "server-up"} + info.update(overrides) + monkeypatch.setattr(su, "inspect_self", lambda: info) + return info + + +@pytest.mark.parametrize("image,verwacht", [ + ("git.example.com/bes-r/server-up:0.5.00", "git.example.com/bes-r/server-up"), + ("git.example.com/bes-r/server-up", "git.example.com/bes-r/server-up"), + ("server-up:latest", "server-up"), + # Poortnummer in de registryhost mag niet als tag gelezen worden. + ("git.example.com:3000/bes-r/server-up", "git.example.com:3000/bes-r/server-up"), + ("git.example.com:3000/bes-r/server-up:1.2.3", "git.example.com:3000/bes-r/server-up"), +]) +def test_image_repo_afleiding(su, monkeypatch, image, verwacht): + _fake_inspect(su, monkeypatch, image=image) + assert su.image_repo() == verwacht + + +def test_expliciet_ingesteld_image_wint(su, env, monkeypatch): + _fake_inspect(su, monkeypatch) + env["core"].patch({"UPDATE_IMAGE": "registry.intern/team/server-up"}) + assert su.image_repo() == "registry.intern/team/server-up" + + +def test_status_ok_bij_registry_image(su, monkeypatch): + _fake_inspect(su, monkeypatch) + st = su.status() + assert st["can_apply"] is True + assert st["image"] == "git.example.com/bes-r/server-up" + + +def test_status_weigert_lokaal_gebouwd_image(su, monkeypatch): + _fake_inspect(su, monkeypatch, image="server-up:latest") + st = su.status() + assert st["can_apply"] is False + assert "registry" in st["reason"] + + +def test_status_weigert_zonder_compose_labels(su, monkeypatch): + _fake_inspect(su, monkeypatch, working_dir="") + st = su.status() + assert st["can_apply"] is False + assert "compose" in st["reason"] + + +def test_status_zonder_container(su, monkeypatch): + monkeypatch.setattr(su, "inspect_self", lambda: {}) + st = su.status() + assert st["can_apply"] is False + assert "niet te vinden" in st["reason"] + + +@pytest.mark.parametrize("tag", [ + "../../etc", "0.5.0; rm -rf /", "-x", "", "a" * 200, "tag met spatie", +]) +def test_pull_weigert_ongeldige_tag(su, monkeypatch, tag): + _fake_inspect(su, monkeypatch) + ok, msg = su.pull(tag) + assert ok is False + assert "Ongeldige tag" in msg + + +def test_write_tag_maakt_env_aan(su, tmp_path): + su._write_tag(str(tmp_path), "0.5.10") + assert (tmp_path / ".env").read_text().strip() == "SU_TAG=0.5.10" + + +def test_write_tag_vervangt_bestaande_regel(su, tmp_path): + (tmp_path / ".env").write_text("BIND=0.0.0.0\nSU_TAG=0.5.00\nPORT=5000\n") + su._write_tag(str(tmp_path), "0.5.10") + regels = (tmp_path / ".env").read_text().splitlines() + assert regels == ["BIND=0.0.0.0", "SU_TAG=0.5.10", "PORT=5000"] + assert sum(1 for r in regels if r.startswith("SU_TAG=")) == 1 + + +def test_write_tag_behoudt_andere_instellingen(su, tmp_path): + (tmp_path / ".env").write_text("SU_IMAGE=git.example.com/bes-r/server-up\n") + su._write_tag(str(tmp_path), "0.5.10") + inhoud = (tmp_path / ".env").read_text() + assert "SU_IMAGE=git.example.com/bes-r/server-up" in inhoud + assert "SU_TAG=0.5.10" in inhoud + + +def test_rollback_zonder_vorige_versie(su): + ok, msg = su.rollback() + assert ok is False + assert "Geen vorige versie" in msg + + +def test_apply_endpoint_weigert_zonder_registry(client, monkeypatch): + from core import selfupdate + csrf = login(client) + monkeypatch.setattr(selfupdate, "inspect_self", + lambda: {"image": "server-up:latest", "working_dir": "/opt/x", + "service": "server-up"}) + r = client.post("/api/update/apply", json={"tag": "0.5.10"}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + assert "registry" in r.get_json()["msg"] + + +def test_rollback_endpoint_weigert_zonder_vorige_tag(client, monkeypatch): + from core import selfupdate + csrf = login(client) + monkeypatch.setattr(selfupdate, "inspect_self", + lambda: {"image": "git.example.com/bes-r/server-up:0.5.00", + "working_dir": "/opt/x", "service": "server-up"}) + r = client.post("/api/update/rollback", headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400 + assert "Geen vorige versie" in r.get_json()["msg"] + + +def test_registry_token_lekt_niet_via_settings(client, env): + csrf = login(client) + client.put("/api/settings", + json={"UPDATE_REGISTRY_USER": "bes-r", + "UPDATE_REGISTRY_TOKEN": "geheim-package-token"}, + headers={"X-CSRF-Token": csrf}) + + body = client.get("/api/settings").get_data(as_text=True) + assert "geheim-package-token" not in body + assert "has_update_registry_token" in body + # Wel echt opgeslagen: + assert env["core"].load()["UPDATE_REGISTRY_TOKEN"] == "geheim-package-token" + + +def test_leeg_registry_token_wist_bestaande_niet(client, env): + csrf = login(client) + client.put("/api/settings", json={"UPDATE_REGISTRY_TOKEN": "blijf-staan"}, + headers={"X-CSRF-Token": csrf}) + client.put("/api/settings", json={"UPDATE_REGISTRY_TOKEN": ""}, + headers={"X-CSRF-Token": csrf}) + assert env["core"].load()["UPDATE_REGISTRY_TOKEN"] == "blijf-staan" diff --git a/tests/test_update_channels.py b/tests/test_update_channels.py new file mode 100644 index 0000000..d3badc2 --- /dev/null +++ b/tests/test_update_channels.py @@ -0,0 +1,115 @@ +"""Update-kanalen: stable ziet alleen releases, beta ook pre-releases.""" +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "server-up")) + +RELEASES = [ + {"tag_name": "v0.5.00", "prerelease": False, "html_url": "u/0.5.00", "body": "stabiel"}, + {"tag_name": "v0.5.10-beta1", "prerelease": True, "html_url": "u/beta1", "body": "beta"}, + {"tag_name": "v0.4.60", "prerelease": False, "html_url": "u/0.4.60", "body": "oud"}, + {"tag_name": "v9.9.9", "prerelease": False, "draft": True, "html_url": "u/draft"}, +] + + +@pytest.fixture +def upd(env, monkeypatch): + from core import updater + updater.invalidate() + monkeypatch.setattr(updater, "_releases", lambda url: RELEASES) + env["core"].patch({"UPDATE_API_URL": "http://forgejo.test/api/releases"}) + return updater + + +def test_stable_negeert_prereleases(upd): + res = upd.check("0.4.60", force=True) + assert res["channel"] == "stable" + assert res["latest"] == "0.5.00" + assert res["update_available"] is True + + +def test_beta_ziet_de_prerelease(upd, env): + env["core"].patch({"UPDATE_CHANNEL": "beta"}) + res = upd.check("0.5.00", force=True) + assert res["channel"] == "beta" + assert res["latest"] == "0.5.10-beta1" + assert res["update_available"] is True + assert res["prerelease"] is True + + +def test_stable_biedt_geen_update_vanaf_nieuwere_beta(upd, env): + """Draai je 0.5.10-beta1 op stable, dan is 0.5.00 géén update.""" + env["core"].patch({"UPDATE_CHANNEL": "stable"}) + res = upd.check("0.5.10-beta1", force=True) + assert res["latest"] == "0.5.00" + assert res["update_available"] is False + + +def test_stabiele_release_wint_van_gelijke_beta(upd, env): + """v0.5.10 hoort hoger te zijn dan v0.5.10-beta1.""" + env["core"].patch({"UPDATE_CHANNEL": "beta"}) + import core.updater as u + assert u._parse("0.5.10") > u._parse("0.5.10-beta1") + + +def test_draft_wordt_overgeslagen(upd): + assert upd.check("0.1.0", force=True)["latest"] != "9.9.9" + + +def test_oude_bool_migreert_naar_beta(upd, env): + env["core"].patch({"UPDATE_INCLUDE_PRERELEASE": True}) + env["core"].patch({"UPDATE_CHANNEL": ""}) + assert upd.channel() == "beta" + + +def test_expliciet_kanaal_wint_van_oude_bool(upd, env): + env["core"].patch({"UPDATE_INCLUDE_PRERELEASE": True, "UPDATE_CHANNEL": "stable"}) + assert upd.channel() == "stable" + + +def test_cache_voorkomt_herhaalde_netwerkverzoeken(upd, monkeypatch): + tellers = {"n": 0} + + def _tel(url): + tellers["n"] += 1 + return RELEASES + + monkeypatch.setattr(upd, "_releases", _tel) + upd.check("0.4.60", force=True) + upd.check("0.4.60") + upd.check("0.4.60") + assert tellers["n"] == 1 + upd.check("0.4.60", force=True) + assert tellers["n"] == 2 + + +def test_zonder_url_uitgeschakeld(upd, env): + env["core"].patch({"UPDATE_API_URL": ""}) + res = upd.check("0.4.60", force=True) + assert res["enabled"] is False + assert res["update_available"] is False + + +def test_netwerkfout_wordt_gemeld(upd, monkeypatch): + def _stuk(url): + raise TimeoutError("te traag") + monkeypatch.setattr(upd, "_releases", _stuk) + res = upd.check("0.4.60", force=True) + assert "TimeoutError" in res["error"] + assert res["update_available"] is False + + +def test_is_prerelease(): + from core import updater + assert updater.is_prerelease("0.5.00-beta") is True + assert updater.is_prerelease("0.5.00") is False + + +def test_ongeldig_kanaal_wordt_geweigerd(client): + from conftest import login + csrf = login(client) + r = client.put("/api/settings", json={"UPDATE_CHANNEL": "onzin"}, + headers={"X-CSRF-Token": csrf}) + assert r.status_code == 400