v0.5.10-beta - update-systeem met kanalen en een-klik bijwerken
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 4s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
This commit is contained in:
Ramon 2026-07-26 15:13:18 +02:00
parent 8b29553a71
commit 8ff5f4b1d3
14 changed files with 1297 additions and 65 deletions

View file

@ -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 # Dit image is wat de "Nu bijwerken"-knop in Server Up ophaalt. Draait bij elke
# deploy-prod.yml / deploy.yml (rsync + `docker compose build`), dus een # v*-tag, zodat elke release (en pre-release) een image krijgt met dezelfde tag
# registry-push is niet nodig. Stond dit op `push: tags: v*`, dan bleef de job # als de release zelf: v0.5.10 → server-up:0.5.10.
# hangen omdat hij een runner met label `docker` zoekt die niet bestaat —
# dat veroorzaakte de eindeloos "building" run in de Actions-tab.
# #
# Wil je tóch naar de Forgejo container-registry publiceren: start deze workflow # Vereisten (Repository → Settings):
# handmatig via de Actions-tab (Run workflow) en zorg dat er een runner met # Variables → REGISTRY = git.example.com (host van je Forgejo)
# label `docker` bestaat (of pas `runs-on` aan naar je eigen runner-label). # OWNER = bes-r (gebruiker/organisatie)
# Secrets → PACKAGE_TOKEN = <token met write:package>
# #
# Vereisten (alleen voor handmatige runs): # Zie docs/updates.md voor de complete instelling.
# Settings → Variables → REGISTRY=git.example.com, OWNER=bes-r
# Settings → Secrets → PACKAGE_TOKEN=<token met write:package scope>
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: on:
workflow_dispatch: workflow_dispatch:
inputs:
tag:
description: "Versie om te bouwen (bv. 0.5.10). Leeg = VERSION-bestand"
required: false
jobs: jobs:
build: build:
runs-on: docker runs-on: [self-hosted, prod]
timeout-minutes: 30
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Extract versie uit tag - name: Versie en tags bepalen
id: tag id: meta
run: echo "v=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
- name: Login op Forgejo registry
run: | 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 }}" \ echo "${{ secrets.PACKAGE_TOKEN }}" \
| docker login ${{ vars.REGISTRY }} \ | docker login "${{ vars.REGISTRY }}" \
-u ${{ vars.OWNER }} \ -u "${{ vars.OWNER }}" --password-stdin
--password-stdin
- name: Bouw image met versie ingebakken - name: Bouwen
run: | run: |
set -euo pipefail
docker build \ docker build \
--build-arg SU_VERSION=${{ steps.tag.outputs.v }} \ --pull \
-t ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:${{ steps.tag.outputs.v }} \ --build-arg "SU_VERSION=${VERSION}" \
-t ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:latest \ -t "${IMAGE}:${VERSION}" \
-t "${IMAGE}:${EXTRA_TAG}" \
. .
- name: Push image - name: Pushen
run: | run: |
docker push ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:${{ steps.tag.outputs.v }} set -euo pipefail
docker push ${{ vars.REGISTRY }}/${{ vars.OWNER }}/server-up:latest 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

View file

@ -60,13 +60,41 @@ jobs:
working-directory: ${{ env.DEPLOY_DIR }} working-directory: ${{ env.DEPLOY_DIR }}
run: echo "${VERSION}" > VERSION run: echo "${VERSION}" > VERSION
- name: Build image - name: Bouwen (en pushen naar de registry)
working-directory: ${{ env.DEPLOY_DIR }} working-directory: ${{ env.DEPLOY_DIR }}
run: | 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 build --pull
# docker compose bouwt als server-up:latest; voeg versie- en channel-tags toe
docker tag server-up:latest "server-up:${VERSION}" if [ -n "$SU_IMAGE" ]; then
docker tag server-up:latest "server-up:${CHANNEL}" 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 - name: Deploy
working-directory: ${{ env.DEPLOY_DIR }} working-directory: ${{ env.DEPLOY_DIR }}

View file

@ -63,10 +63,14 @@ jobs:
- name: Build image - name: Build image
working-directory: ${{ env.DEPLOY_DIR }} working-directory: ${{ env.DEPLOY_DIR }}
run: | 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 build --pull
# docker compose bouwt als server-up:latest; voeg versie- en channel-tags toe docker tag "server-up:${VERSION}" "server-up:${CHANNEL}" || true
docker tag server-up:latest "server-up:${VERSION}"
docker tag server-up:latest "server-up:${CHANNEL}"
- name: Deploy - name: Deploy
working-directory: ${{ env.DEPLOY_DIR }} working-directory: ${{ env.DEPLOY_DIR }}

View file

@ -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 # v0.5.00-beta — Authenticatie, beveiliging en eigen IP-adressen
> **Let op bij het bijwerken.** Server Up heeft nu een login. Open na het > **Let op bij het bijwerken.** Server Up heeft nu een login. Open na het

View file

@ -1 +1 @@
0.5.00-beta 0.5.10-beta

View file

@ -6,7 +6,11 @@ services:
# Bakt de versie in het image. De deploy-workflow zet SU_VERSION # Bakt de versie in het image. De deploy-workflow zet SU_VERSION
# (git-tag of main-<sha>); zonder waarde valt het terug op 'dev'. # (git-tag of main-<sha>); zonder waarde valt het terug op 'dev'.
SU_VERSION: ${SU_VERSION:-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 container_name: server-up
hostname: server-up hostname: server-up
user: "0:0" user: "0:0"

278
docs/updates.md Normal file
View file

@ -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:<token> \
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
```

View file

@ -15,7 +15,7 @@ from flask import Flask, Response, jsonify, render_template, request, session
import core as cfg import core as cfg
from core import audit, jobs, i18n from core import audit, jobs, i18n
from core import auth, docker, git, boilerplates, updater 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 from core.modules import Module, CORE, discover
app = Flask(__name__, static_folder="static", template_folder="templates") app = Flask(__name__, static_folder="static", template_folder="templates")
@ -336,7 +336,81 @@ def api_version():
@app.route("/api/update") @app.route("/api/update")
def 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") @app.route("/api/docker/images")
@ -1476,6 +1550,14 @@ def api_settings_put():
blocked = _SETTINGS_HIDE | cfg.SECRET_KEYS blocked = _SETTINGS_HIDE | cfg.SECRET_KEYS
updates = {k: v for k, v in d.items() if k not in blocked} updates = {k: v for k, v in d.items() if k not in blocked}
current = cfg.load() 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"): for key in ("APP_REPOS", "MODULE_REPOS"):
if key not in updates: if key not in updates:
continue continue

View file

@ -36,7 +36,18 @@ DEFAULTS = {
# Leeg = uitgeschakeld. Voorbeeld (Forgejo): # Leeg = uitgeschakeld. Voorbeeld (Forgejo):
# http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases # http://10.0.20.22:3000/api/v1/repos/bes-r/server-up/releases
"UPDATE_API_URL": os.environ.get("SU_UPDATE_API", ""), "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 # Authenticatie — beheerd via core.auth; hier alleen als placeholder zodat
# load() de sleutel kent. Wordt nooit via /api/settings teruggegeven. # load() de sleutel kent. Wordt nooit via /api/settings teruggegeven.
"AUTH": {}, "AUTH": {},
@ -47,9 +58,14 @@ DEFAULTS = {
"NETWORKS": [], "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"}) 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")) _path: Path = Path(os.environ.get("SU_CONFIG", "/data/config.json"))
_lock = threading.RLock() _lock = threading.RLock()
@ -118,6 +134,10 @@ def redact(config: dict) -> dict:
dát er een token is zonder de waarde prijs te geven. 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} 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"): for key in ("APP_REPOS", "MODULE_REPOS"):
repos = out.get(key) repos = out.get(key)
if not isinstance(repos, list): if not isinstance(repos, list):

View file

@ -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 <registry>/<repo>:<tag>` nieuw image binnenhalen
2. `SU_TAG=<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 `<repo>:<tag>` 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 <service>` 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)

View file

@ -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 from __future__ import annotations
import json import json
import re import re
import threading
import time
import urllib.request import urllib.request
import core as cfg import core as cfg
_SEMVER = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)(?:[-+.](.+))?$") _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): def _pre_key(pre: str):
parts = [p for p in re.split(r"[.+-]", pre) if p] if pre else [] 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)) 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() c = cfg.load()
url = (c.get("UPDATE_API_URL") or "").strip() ch = (c.get("UPDATE_CHANNEL") or "").strip().lower()
include_pre = bool(c.get("UPDATE_INCLUDE_PRERELEASE")) if ch in CHANNELS:
res = {"enabled": bool(url), "current": current, "latest": None, return ch
"update_available": False, "url": "", "notes": "", # Oude instelling: UPDATE_INCLUDE_PRERELEASE=true betekende 'beta'.
"prerelease": False, "error": ""} if c.get("UPDATE_INCLUDE_PRERELEASE"):
if not url: return "beta"
return res return "stable"
try:
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( req = urllib.request.Request(
url, headers={"Accept": "application/json", "User-Agent": "server-up"}) url, headers={"Accept": "application/json", "User-Agent": "server-up"})
with urllib.request.urlopen(req, timeout=8) as r: with urllib.request.urlopen(req, timeout=8) as r:
data = json.loads(r.read().decode("utf-8")) 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()
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:
rels = _releases(url)
except Exception as e: except Exception as e:
res["error"] = f"{type(e).__name__}: {e}" res["error"] = f"{type(e).__name__}: {e}"
return res return res
rels = data if isinstance(data, list) else [data]
best, best_t = None, None best, best_t = None, None
for rel in rels: for rel in rels:
if not isinstance(rel, dict) or rel.get("draft"): if not isinstance(rel, dict) or rel.get("draft"):
continue 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 continue
t = _parse(rel.get("tag_name") or rel.get("name") or "") t = _parse(rel.get("tag_name") or rel.get("name") or "")
if not t: if not t:
@ -57,13 +116,28 @@ def check(current: str) -> dict:
if best_t is None or t > best_t: if best_t is None or t > best_t:
best, best_t = rel, t best, best_t = rel, t
if not best: if not best:
res["error"] = f"Geen releases gevonden in kanaal '{ch}'"
return res 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["url"] = best.get("html_url") or best.get("url") or ""
res["notes"] = best.get("body") or "" res["notes"] = best.get("body") or ""
res["prerelease"] = bool(best.get("prerelease")) res["prerelease"] = bool(best.get("prerelease"))
cur_t = _parse(current) cur_t = _parse(current)
if cur_t and best_t and best_t > cur_t: if cur_t and best_t and best_t > cur_t:
res["update_available"] = True res["update_available"] = True
with _LOCK:
_CACHE = dict(res)
_CACHE_TS = time.time()
return res 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

View file

@ -550,20 +550,74 @@ tailwind.config = {
<pre class="p-3.5 text-xs whitespace-pre-wrap break-words text-t2 dark:text-t2-dark max-h-64 overflow-auto" x-text="update.notes"></pre> <pre class="p-3.5 text-xs whitespace-pre-wrap break-words text-t2 dark:text-t2-dark max-h-64 overflow-auto" x-text="update.notes"></pre>
</details> </details>
</template> </template>
<template x-if="update.update_available"> <div class="flex flex-wrap gap-2" x-show="update.update_available">
<a :href="update.url" target="_blank" rel="noopener" class="btn btn-primary w-max"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a> <button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating">
</template> <span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span>
<span x-text="updating ? 'Bezig…' : ('Nu bijwerken naar v'+update.latest)"></span>
</button>
<a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a>
</div>
<!-- Waarom de knop niet bruikbaar is (geen registry-image, geen
compose-labels, …) — anders staat er alleen een dode knop. -->
<p x-show="update.update_available && !update.can_apply"
class="text-xs text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span>
<span><span x-text="update.apply_reason"></span>
Bijwerken kan wel via een git-tag; zie <span class="font-mono">docs/updates.md</span>.</span>
</p>
<p x-show="update.error" class="text-xs text-er" x-text="update.error"></p> <p x-show="update.error" class="text-xs text-er" x-text="update.error"></p>
<p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark">
Laatst gecontroleerd: <span x-text="fmtTime(update.checked_at)"></span>
</p>
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3"> <div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
<div>
<label class="form-label">Kanaal</label>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">
<span x-show="updChannel()==='stable'">Alleen stabiele releases.</span>
<span x-show="updChannel()==='beta'">Ook pre-releases (bv. v0.5.10-beta1). Handig om mee te testen, minder voorspelbaar.</span>
</p>
</div>
<div> <div>
<label class="form-label" x-text="t('update_api_url')">Releases-API URL</label> <label class="form-label" x-text="t('update_api_url')">Releases-API URL</label>
<input class="form-input font-mono" x-model="settings.UPDATE_API_URL" @change="autoSave(); setTimeout(()=>loadUpdate(), 600)" placeholder="http://git.example.com/api/v1/repos/bes-r/server-up/releases"> <input class="form-input font-mono" x-model="settings.UPDATE_API_URL" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="http://git.example.com/api/v1/repos/bes-r/server-up/releases">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p> <p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p>
</div> </div>
<label class="inline-flex items-center gap-2.5 cursor-pointer"> <div>
<input type="checkbox" :checked="!!settings.UPDATE_INCLUDE_PRERELEASE" @change="settings.UPDATE_INCLUDE_PRERELEASE=$event.target.checked; autoSave(); setTimeout(()=>loadUpdate(), 600)" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac"> <label class="form-label">Image (voor bijwerken vanuit de interface)</label>
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('include_prerelease')">Pre-releases (beta) meenemen</span> <input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up">
</label> <p class="text-[11px] text-dm dark:text-dm-dark mt-1">
Zonder tag. Leeg = afleiden uit de draaiende container.
</p>
</div>
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label">Registry-gebruiker (optioneel)</label>
<input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r">
</div>
<div>
<label class="form-label">Registry-token</label>
<input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()"
:placeholder="settings.has_update_registry_token ? '•••••••• (ingesteld)' : 'alleen bij een privé registry'">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1">Leeg laten = ongewijzigd.</p>
</div>
</div>
<div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark">
<label class="form-label">Terugrollen</label>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-t2 dark:text-t2-dark">Vorige versie:
<span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span>
<button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating">
<span class="mdi mdi-undo-variant"></span> Terug naar deze versie
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -1043,7 +1097,10 @@ function app() {
settings: {}, settings: {},
languages: [], languages: [],
modules: [], 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: {}, T: {},
job: { id: null, lines: [], status: 'idle', timer: null }, 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 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() { async checkWizard() {
const d = await this.rpc('/api/wizard'); const d = await this.rpc('/api/wizard');

156
tests/test_selfupdate.py Normal file
View file

@ -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"

View file

@ -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