From f8303754f717cf80651b3ff9b5c8d3d5d1327cbe Mon Sep 17 00:00:00 2001 From: Jos Rothman Date: Thu, 5 Nov 2026 19:30:34 +0100 Subject: [PATCH] Releases on Gitea, one-line installer and auto-update (1.1.0) - .github/workflows/release.yml: a tag vX.Y.Z runs the tests, publishes daynivo-kiosk-os-X.Y.Z.tar.gz (+ .sha256) and install-online.sh as a Gitea release, then builds the SD image with pi-gen and attaches it. - install-online.sh: curl | sudo bash installer for the latest release. - voxip-kiosk-updater: checks the latest release, verifies and unpacks a newer one under /opt/daynivo-kiosk-os and runs its update.sh. A daily timer checks around 03:00 and installs when auto_update is on. - Web interface: Systeem -> Software-updates with the auto-update switch, check now and install now (helper verbs update-check / update-install). - CI checks out with plain git, so it runs on Gitea's act_runner too. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 +- .github/workflows/release.yml | 77 +++++++ Makefile | 2 +- README.md | 64 +++++- VERSION | 2 +- bin/voxip-kiosk-updater | 9 + config/config.default.json | 3 +- docs/ARCHITECTURE.md | 10 +- docs/HARDWARE-TEST.md | 8 + install-online.sh | 63 ++++++ install.sh | 9 +- src/voxip_kiosk/config.py | 2 + src/voxip_kiosk/helper.py | 23 +- src/voxip_kiosk/paths.py | 4 + src/voxip_kiosk/updater.py | 244 ++++++++++++++++++++++ src/voxip_kiosk/web/static/app.css | 1 + src/voxip_kiosk/web/templates/system.html | 34 +++ src/voxip_kiosk/web/views.py | 49 ++++- systemd/voxip-kiosk-update.service | 11 + systemd/voxip-kiosk-update.timer | 11 + tests/test_helper.py | 17 ++ tests/test_updater.py | 198 ++++++++++++++++++ tests/test_web.py | 21 ++ tools/release-upload.sh | 42 ++++ uninstall.sh | 4 +- 25 files changed, 899 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 bin/voxip-kiosk-updater create mode 100755 install-online.sh create mode 100644 src/voxip_kiosk/updater.py create mode 100644 systemd/voxip-kiosk-update.service create mode 100644 systemd/voxip-kiosk-update.timer create mode 100644 tests/test_updater.py create mode 100755 tools/release-upload.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f353142..cfd5b64 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: ["**"] pull_request: jobs: @@ -10,16 +11,20 @@ jobs: runs-on: ubuntu-latest container: debian:bookworm steps: - - uses: actions/checkout@v4 - name: Install Debian packages run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - python3 python3-flask python3-argon2 python3-websocket python3-pytest shellcheck + ca-certificates git python3 python3-flask python3-argon2 python3-websocket python3-pytest shellcheck + - name: Checkout + # Plain git: JavaScript actions need Node, which debian:bookworm doesn't have. + run: | + git clone "${{ github.server_url }}/${{ github.repository }}.git" . + git checkout "${{ github.sha }}" - name: Tests run: python3 -m pytest -p no:cacheprovider - name: Shellcheck - run: shellcheck -S warning install.sh update.sh uninstall.sh image/build.sh image/stage-voxip/prerun.sh image/stage-voxip/00-voxip/00-run.sh bin/voxip-kiosk-x + run: shellcheck -S warning install.sh install-online.sh update.sh uninstall.sh tools/release-upload.sh image/build.sh image/stage-voxip/prerun.sh image/stage-voxip/00-voxip/00-run.sh bin/voxip-kiosk-x test-latest: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..da5c21d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +# Gitea Actions: a tag vX.Y.Z (matching VERSION) becomes a release with +# daynivo-kiosk-os-X.Y.Z.tar.gz (+ .sha256) installed by install-online.sh and the updater +# install-online.sh the one-line installer +# -daynivo-kiosk-os-X.Y.Z.img.xz (+ .sha256), built afterwards (30–90+ minutes) +# +# Runner requirements: act_runner with the "ubuntu-latest" label, and for the +# image job `container.privileged: true` in its config (pi-gen needs loop +# devices and binfmt_misc). A secret RELEASE_TOKEN (repository write access) +# is used when set, otherwise the job's own token. +name: Release + +on: + push: + tags: ["v*"] + +env: + GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }} + GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITEA_TOKEN }} + +jobs: + package: + runs-on: ubuntu-latest + container: debian:bookworm + steps: + - name: Tools + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl git python3 python3-flask python3-argon2 python3-websocket python3-pytest shellcheck + - name: Checkout + # Plain git: JavaScript actions need Node, which debian:bookworm doesn't have. + run: git clone --depth 1 --branch "${{ github.ref_name }}" "${{ github.server_url }}/${{ github.repository }}.git" src + - name: Tag matches VERSION + working-directory: src + run: test "v$(tr -d '[:space:]' < VERSION)" = "${{ github.ref_name }}" + - name: Tests + working-directory: src + run: | + python3 -m pytest -q -p no:cacheprovider + shellcheck -S warning install.sh install-online.sh update.sh uninstall.sh tools/release-upload.sh image/build.sh + - name: Package + working-directory: src + run: | + V="$(tr -d '[:space:]' < VERSION)" + mkdir -p ../dist + git archive --prefix="daynivo-kiosk-os-$V/" -o "../dist/daynivo-kiosk-os-$V.tar.gz" HEAD + (cd ../dist && sha256sum "daynivo-kiosk-os-$V.tar.gz" > "daynivo-kiosk-os-$V.tar.gz.sha256") + cp install-online.sh ../dist/ + - name: Publish + run: src/tools/release-upload.sh "${{ github.ref_name }}" dist/* + + image: + needs: package + runs-on: ubuntu-latest + container: + image: debian:bookworm + options: --privileged + timeout-minutes: 360 + steps: + - name: Tools (pi-gen) + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl git python3 openssl coreutils quilt parted qemu-user-static binfmt-support \ + debootstrap zerofree zip dosfstools libarchive-tools libcap2-bin grep rsync xz-utils file bc gpg \ + pigz xxd arch-test bmap-tools kmod fdisk udev + - name: Enable ARM emulation + run: | + mountpoint -q /proc/sys/fs/binfmt_misc || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc + update-binfmts --enable qemu-arm + - name: Checkout + run: git clone --depth 1 --branch "${{ github.ref_name }}" "${{ github.server_url }}/${{ github.repository }}.git" src + - name: Build image + working-directory: src + run: ./image/build.sh --native + - name: Publish + run: src/tools/release-upload.sh "${{ github.ref_name }}" src/image/deploy/*.img.xz src/image/deploy/*.img.xz.sha256 diff --git a/Makefile b/Makefile index ecc12b8..6abf32f 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ test-bookworm: apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ python3 python3-flask python3-argon2 python3-websocket python3-pytest shellcheck >/dev/null && \ cp -r /src /work && cd /work && rm -rf .venv && python3 -m pytest -p no:cacheprovider && \ - shellcheck -S warning install.sh update.sh uninstall.sh image/build.sh bin/voxip-kiosk-x' + shellcheck -S warning install.sh install-online.sh update.sh uninstall.sh tools/release-upload.sh image/build.sh bin/voxip-kiosk-x' # Web interface on http://127.0.0.1:8080 with throwaway state in .dev/ (system actions fail gracefully). dev: diff --git a/README.md b/README.md index d5bb2f0..2393672 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ hardware staat in [`docs/HARDWARE-TEST.md`](docs/HARDWARE-TEST.md). ### Optie A — kant-en-klare image -1. Bouw de image (zie hieronder) of gebruik een uitgebrachte - `*-daynivo-kiosk-os-*.img.xz`. +1. Download de nieuwste `*-daynivo-kiosk-os-*.img.xz` bij de + [releases](https://git.rothman.nl/rothman/daynivo-kiosk-os/releases), + of bouw hem zelf (zie hieronder). 2. Schrijf hem met **Raspberry Pi Imager** (*Eigen image gebruiken*) of **balenaEtcher** naar een microSD-kaart van 16 GB of groter. 3. Optioneel, in Raspberry Pi Imager → *OS-aanpassingen*: wifi, gebruiker @@ -44,8 +45,17 @@ Vereist: Raspberry Pi OS Lite Bookworm of Trixie met NetworkManager (de standaard sinds Bookworm). ```bash -sudo apt-get install -y git -git clone daynivo-kiosk-os +curl -fsSL https://daynivo.nl/kiosk/install.sh | sudo bash -s -- \ + --url "https://schermen-ijsselflat.voxip.nl" --name "IJsselflat" --hostname ijsselflat +``` + +`install-online.sh` (dat is wat daynivo.nl serveert, en het hangt ook bij +elke release) haalt de nieuwste release op, controleert de SHA-256, pakt hem +uit in `/opt/daynivo-kiosk-os/` en draait daar `install.sh`. Git is +niet nodig. Vanuit een clone kan het ook: + +```bash +git clone https://git.rothman.nl/rothman/daynivo-kiosk-os.git cd daynivo-kiosk-os sudo ./install.sh --url "https://schermen-ijsselflat.voxip.nl" --name "IJsselflat" --hostname ijsselflat ``` @@ -60,6 +70,23 @@ de Daynivo-repository)? Dan neemt `install.sh` de display-URL (inclusief ### Updaten +In de webinterface onder **Systeem → Software-updates**: *Nu controleren*, +*Bijwerken naar …*, en het vinkje **Automatisch bijwerken**. Elke nacht +tussen 03:00 en 05:00 (`voxip-kiosk-update.timer`) kijkt de kiosk of er een +nieuwe release is; met het vinkje aan installeert hij die meteen. Dat werkt +ook op een apparaat dat met de image of vanuit een clone is geïnstalleerd. +Vanaf de commandoregel: + +```bash +sudo /usr/lib/voxip-kiosk/voxip-kiosk-updater --check # alleen kijken +sudo /usr/lib/voxip-kiosk/voxip-kiosk-updater --install # nieuwste release installeren +``` + +De updater installeert alleen een nieuwere versie met een kloppende +checksum, pakt hem uit in `/opt/daynivo-kiosk-os/` (de laatste twee +blijven bewaard) en draait daar `update.sh --no-pull`. Vanuit een clone kan +het ook met git: + ```bash cd daynivo-kiosk-os sudo ./update.sh # git pull --ff-only + back-up van /etc/voxip-kiosk + install.sh --upgrade @@ -117,6 +144,34 @@ bouwen zijn. Er bestaat geen snapshot-archief voor Raspberry Pi OS, dus twee builds op verschillende dagen kunnen nieuwere pakketversies bevatten. Bewaar voor een uitrol daarom de gebouwde `.img.xz` zelf. +## Releases + +Een release maak je door `VERSION` te verhogen, te committen en een tag met +hetzelfde nummer te pushen: + +```bash +echo 1.2.0 > VERSION && git commit -am "Release 1.2.0" +git tag v1.2.0 && git push && git push origin v1.2.0 +``` + +Gitea Actions (`.github/workflows/release.yml`) draait dan de tests en maakt +een release met `daynivo-kiosk-os-.tar.gz` (+ `.sha256`) en +`install-online.sh`. Daarna bouwt dezelfde workflow het SD-image en hangt het +`.img.xz` (+ `.sha256`) aan dezelfde release; dat duurt 30 tot 90 minuten of +langer. Zodra de tar-bestanden er staan, zien de kiosks de nieuwe versie. + +Vereist op de Gitea-server: + +- een **act_runner** met het label `ubuntu-latest`; +- voor het image: `container.privileged: true` in de runnerconfiguratie + (pi-gen heeft loop-devices en binfmt_misc nodig) en genoeg schijfruimte + (± 15 GB tijdens het bouwen); +- de maximale bijlagegrootte (`[attachment] MAX_SIZE` in `app.ini`, in MB) + groter dan het image (± 1 GB); +- optioneel een repository-secret `RELEASE_TOKEN` (token met schrijfrecht op + de repository), als het standaardtoken van de workflow geen releases mag + maken. + ## Beheer ### Webinterface — `http://.local:8080` @@ -164,6 +219,7 @@ journalctl -u voxip-kiosk-browser -u voxip-kiosk-agent -f | `admin_port` | `8080` | poort van de beheerinterface | | `admin_https` | `false` | HTTPS met zelfondertekend certificaat | | `admin_allow_public` | `false` | ook toegang vanaf publieke IP-adressen (afgeraden) | +| `auto_update` | `false` | elke nacht een nieuwe release installeren | Wijzigingen via de webinterface of `voxip-kiosk-ctl` worden atomair opgeslagen, met een `.bak` van de vorige versie. Een URL-wijziging gaat in diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/bin/voxip-kiosk-updater b/bin/voxip-kiosk-updater new file mode 100755 index 0000000..cb48cf4 --- /dev/null +++ b/bin/voxip-kiosk-updater @@ -0,0 +1,9 @@ +#!/usr/bin/python3 -I +# Installed to /usr/lib/voxip-kiosk/ by install.sh. -I: ignore PYTHON* env and user site-packages. +import sys + +sys.path.insert(0, "/usr/lib/voxip-kiosk/python") + +from voxip_kiosk.updater import main # noqa: E402 + +sys.exit(main()) diff --git a/config/config.default.json b/config/config.default.json index 52bc4ea..1623005 100644 --- a/config/config.default.json +++ b/config/config.default.json @@ -11,5 +11,6 @@ "browser_profile": "persistent", "admin_port": 8080, "admin_https": false, - "admin_allow_public": false + "admin_allow_public": false, + "auto_update": false } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ecec233..989da85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -50,7 +50,8 @@ samenwerken en waarom bepaalde keuzes zijn gemaakt. | `voxip-kiosk-browser` | `kiosk` | Past rotatie/resolutie/DPMS toe (xrandr/xset), ruimt een vastgelopen profiel op en start Chromium. | | `voxip-kiosk-agent` | `kiosk` | Bestuurt Chromium via het DevTools Protocol op localhost: offline-pagina, URL-wissel zonder herstart, periodiek verversen, hang-detectie. | | `voxip-kiosk-manager` | `voxip` | Flask-webinterface (gunicorn, 1 worker, 4 threads). | -| `voxip-kiosk-helper` | `root` (via sudo) | Enige geprivilegieerde ingang: reboot, uitschakelen, Chromium herstarten, hostname, wifi-land, wifi-scan, netwerkconfiguratie + rollback. | +| `voxip-kiosk-helper` | `root` (via sudo) | Enige geprivilegieerde ingang: reboot, uitschakelen, Chromium herstarten, hostname, wifi-land, wifi-scan, netwerkconfiguratie + rollback, updates controleren en starten. | +| `voxip-kiosk-updater` | `root` (timer, of eigen transient unit via de helper) | Haalt de nieuwste release op (HTTPS, SHA-256), pakt hem uit in `/opt/daynivo-kiosk-os/` en draait `update.sh --no-pull`. Nooit een oudere of gelijke versie. | | `voxip-kiosk-healthcheck` | `root` | Bewaakt alle services en herstelt ze, met logging. | De webinterface draait **nooit** als root. De sudoers-regel staat @@ -130,8 +131,11 @@ draait) kan het apparaat dus claimen, iemand anders op het netwerk niet. bij het laden terug op de standaard, zodat de kiosk altijd start. Onbekende sleutels blijven bewaard (vooruit-/achteruitcompatibel). - `/etc/voxip-kiosk/admin.json`: Argon2id-hash, 0600, alleen `voxip`. -- Updates (`install.sh --upgrade` / `update.sh`) overschrijven nooit - bestaande configuratie. +- Updates (`install.sh --upgrade` / `update.sh` / de updater) overschrijven + nooit bestaande configuratie. De updater draait in een eigen systemd-unit, + zodat het herstarten van de beheerinterface tijdens `install.sh` de update + niet afbreekt. De checksum komt van dezelfde server als de download: hij + vangt een kapotte download af, geen gehackte releaseserver. ### Betrouwbaarheid (24/7) diff --git a/docs/HARDWARE-TEST.md b/docs/HARDWARE-TEST.md index 5cef98c..ea66e84 100644 --- a/docs/HARDWARE-TEST.md +++ b/docs/HARDWARE-TEST.md @@ -119,6 +119,14 @@ Noteer per stap: ✅ / ❌ + opmerking, de imageversie en het schermtype. `voxip-config.error.txt` met de reden. De kiosk start met de standaardconfiguratie. - [ ] `sudo ./update.sh` vanuit een nieuwere checkout: versie in het dashboard verhoogd, configuratie en wachtwoord ongewijzigd, back-up in `/var/backups`. +- [ ] Systeem → Software-updates → *Nu controleren* met een nieuwere release + op Gitea: "beschikbaar". *Bijwerken naar …*: binnen enkele minuten is de + nieuwe versie actief, de beheerinterface komt terug, configuratie en + wachtwoord ongewijzigd. +- [ ] *Automatisch bijwerken* aan, `sudo systemctl start voxip-kiosk-update.service`: + de nieuwere release wordt geïnstalleerd. Met het vinkje uit: alleen gecontroleerd. +- [ ] `curl -fsSL https://daynivo.nl/kiosk/install.sh | sudo bash` op een verse + Raspberry Pi OS Lite: installeert de nieuwste release, setupcode op het scherm. - [ ] `sudo ./uninstall.sh --purge` op een testkaart: geen Daynivo-services of -bestanden meer (`systemctl list-unit-files | grep voxip` is leeg). - [ ] Installatie op een bestaande Raspberry Pi OS Lite (Bookworm) met de diff --git a/install-online.sh b/install-online.sh new file mode 100755 index 0000000..766443c --- /dev/null +++ b/install-online.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# Install Daynivo Kiosk OS from the latest release, without git: +# +# curl -fsSL https://daynivo.nl/kiosk/install.sh | sudo bash +# curl -fsSL https://daynivo.nl/kiosk/install.sh | sudo bash -s -- --url "https://…" --name "Hal" +# +# Downloads the newest release from the Gitea repository, checks its SHA-256, +# unpacks it to /opt/daynivo-kiosk-os/ and runs its install.sh with +# the given options (see install.sh --help). Later updates come from the web +# interface (Systeem → Software-updates) or the same updater. + +set -euo pipefail + +API="${DAYNIVO_RELEASE_API:-https://git.rothman.nl/api/v1/repos/rothman/daynivo-kiosk-os/releases/latest}" +DEST=/opt/daynivo-kiosk-os + +say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31mFout:\033[0m %s\n' "$*" >&2; exit 1; } + +# Everything runs from main(), called on the last line: a download that breaks +# off halfway through this script runs nothing. +main() { + [[ $EUID -eq 0 ]] || die "Voer dit uit met sudo (… | sudo bash)." + for tool in curl python3 sha256sum tar; do + command -v "$tool" >/dev/null || die "$tool ontbreekt." + done + + say "Nieuwste release opzoeken" + local json version tarball checksum + json="$(curl -fsSL --proto '=https' "$API")" || die "Kan de releaseserver niet bereiken ($API)." + read -r version tarball checksum < <(python3 -I -c ' +import json, sys +data = json.loads(sys.stdin.read()) +version = data["tag_name"].removeprefix("v") +assets = {a["name"]: a["browser_download_url"] for a in data.get("assets") or []} +name = f"daynivo-kiosk-os-{version}.tar.gz" +print(version, assets[name], assets[name + ".sha256"]) +' <<<"$json") || die "De nieuwste release heeft geen installatiebestand." + [[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Onbekend versienummer: $version" + + say "Daynivo Kiosk OS $version downloaden" + local tmp + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + curl -fsSL --proto '=https' -o "$tmp/release.tar.gz" "$tarball" + curl -fsSL --proto '=https' -o "$tmp/release.sha256" "$checksum" + local expected actual + expected="$(awk '{print $1; exit}' "$tmp/release.sha256")" + actual="$(sha256sum "$tmp/release.tar.gz" | awk '{print $1}')" + [[ -n $expected && $expected == "$actual" ]] || die "Checksum klopt niet; er wordt niets geïnstalleerd." + + tar -C "$tmp" --no-same-owner -xzf "$tmp/release.tar.gz" + [[ "$(tr -d '[:space:]' < "$tmp/daynivo-kiosk-os-$version/VERSION")" == "$version" ]] || die "Release bevat een andere versie." + install -d -m 0755 "$DEST" + rm -rf "${DEST:?}/$version" + mv "$tmp/daynivo-kiosk-os-$version" "$DEST/$version" + + say "Installeren" + "$DEST/$version/install.sh" "$@" +} + +main "$@" diff --git a/install.sh b/install.sh index 6afa024..ab3a8b4 100755 --- a/install.sh +++ b/install.sh @@ -26,9 +26,10 @@ SHARE=/usr/share/voxip-kiosk DOC=/usr/share/doc/voxip-kiosk ETC=/etc/voxip-kiosk UNITS=(voxip-kiosk-x.service voxip-kiosk-browser.service voxip-kiosk-agent.service voxip-kiosk-manager.service - voxip-kiosk-healthcheck.service voxip-kiosk-healthcheck.timer voxip-network-guard.service voxip-boot-import.service) + voxip-kiosk-healthcheck.service voxip-kiosk-healthcheck.timer voxip-network-guard.service voxip-boot-import.service + voxip-kiosk-update.service voxip-kiosk-update.timer) ENABLE=(voxip-kiosk-x.service voxip-kiosk-browser.service voxip-kiosk-agent.service voxip-kiosk-manager.service - voxip-kiosk-healthcheck.timer voxip-network-guard.service voxip-boot-import.service) + voxip-kiosk-healthcheck.timer voxip-network-guard.service voxip-boot-import.service voxip-kiosk-update.timer) say() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mLet op:\033[0m %s\n' "$*" >&2; } @@ -134,7 +135,7 @@ rm -rf "$LIB/python.old" mv "$LIB/python.new" "$LIB/python" rm -rf "$LIB/python.old" -for bin in voxip-kiosk-agent voxip-kiosk-browser voxip-kiosk-healthcheck voxip-kiosk-helper voxip-kiosk-manager voxip-kiosk-x; do +for bin in voxip-kiosk-agent voxip-kiosk-browser voxip-kiosk-healthcheck voxip-kiosk-helper voxip-kiosk-manager voxip-kiosk-updater voxip-kiosk-x; do install -m 0755 -o root -g root "$SRC/bin/$bin" "$LIB/$bin" done install -m 0755 -o root -g root "$SRC/bin/voxip-kiosk-ctl" /usr/local/sbin/voxip-kiosk-ctl @@ -319,7 +320,7 @@ say "Services starten" systemctl restart systemd-journald.service || true systemctl restart avahi-daemon.service || true systemctl restart voxip-kiosk-manager.service voxip-kiosk-agent.service -systemctl start voxip-kiosk-healthcheck.timer +systemctl start voxip-kiosk-healthcheck.timer voxip-kiosk-update.timer systemctl reset-failed voxip-kiosk-x.service voxip-kiosk-browser.service 2>/dev/null || true systemctl restart voxip-kiosk-x.service systemctl restart voxip-kiosk-browser.service diff --git a/src/voxip_kiosk/config.py b/src/voxip_kiosk/config.py index d98183a..13ae4c1 100644 --- a/src/voxip_kiosk/config.py +++ b/src/voxip_kiosk/config.py @@ -36,6 +36,7 @@ DEFAULTS: dict[str, Any] = { "admin_port": 8080, "admin_https": False, "admin_allow_public": False, + "auto_update": False, } @@ -114,6 +115,7 @@ VALIDATORS: dict[str, Callable[[Any], Any]] = { "admin_port": _int_range(1024, 65535, "Poort moet tussen 1024 en 65535 liggen."), "admin_https": _bool, "admin_allow_public": _bool, + "auto_update": _bool, } diff --git a/src/voxip_kiosk/helper.py b/src/voxip_kiosk/helper.py index a00dea6..2b57ad6 100644 --- a/src/voxip_kiosk/helper.py +++ b/src/voxip_kiosk/helper.py @@ -23,12 +23,14 @@ from pathlib import Path from typing import Any, Callable from . import config as cfg -from . import netconfig +from . import netconfig, updater from . import validation as v from .paths import Paths, default_paths HELPER_PATH = "/usr/lib/voxip-kiosk/voxip-kiosk-helper" ROLLBACK_UNIT = "voxip-network-rollback" +UPDATE_UNIT = "voxip-kiosk-update-now" +UPDATER_PATH = "/usr/lib/voxip-kiosk/voxip-kiosk-updater" ACTIVATE_UNIT = "voxip-network-activate" BOOT_DIRS = (Path("/boot/firmware"), Path("/boot")) RESET_TRIGGER = "voxip-network-reset" @@ -41,6 +43,7 @@ SSH_KEY_TYPES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "sk-ssh-ed25519@openss PUBLIC_VERBS = { "reboot", "poweroff", "restart-browser", "restart-manager", "set-hostname", "set-wifi-country", "wifi-scan", "network-apply", "network-confirm", "network-rollback", "network-pending", + "update-check", "update-install", } @@ -99,6 +102,22 @@ class Helper: log(syslog.LOG_NOTICE, "Beheerinterface wordt herstart") return {"message": "De beheerinterface wordt herstart."} + # -- software updates ----------------------------------------------- + + def update_check(self) -> dict[str, Any]: + state, _ = updater.Updater(self.paths).check() + return {"update": state} + + def update_install(self) -> dict[str, Any]: + # A transient unit of its own: install.sh restarts the web interface, which + # must not take the running update down with it. + proc = self.run(["systemd-run", f"--unit={UPDATE_UNIT}", "--collect", "--quiet", + UPDATER_PATH, "--install"], check=False) + if proc.returncode != 0: + raise HelperError("Er loopt al een update, of die kon niet worden gestart.") + log(syslog.LOG_NOTICE, "Update gestart via beheerinterface") + return {"message": "De update is gestart. De beheerinterface is zo meteen even niet bereikbaar."} + # -- hostname ------------------------------------------------------- def set_hostname(self, name: str) -> dict[str, Any]: @@ -403,6 +422,8 @@ class Helper: "network-pending": lambda: (no_args(), self.network_pending())[1], "network-boot-guard": lambda: (no_args(), self.network_boot_guard())[1], "boot-import": lambda: (no_args(), self.boot_import())[1], + "update-check": lambda: (no_args(), self.update_check())[1], + "update-install": lambda: (no_args(), self.update_install())[1], } if verb not in table: raise HelperError(f"Onbekende opdracht: {verb}") diff --git a/src/voxip_kiosk/paths.py b/src/voxip_kiosk/paths.py index 94f4428..7846137 100644 --- a/src/voxip_kiosk/paths.py +++ b/src/voxip_kiosk/paths.py @@ -76,6 +76,10 @@ class Paths: def version_file(self) -> Path: return self.share_dir / "VERSION" + @property + def update_state_file(self) -> Path: + return self.state_dir / "update.json" + @property def audit_log(self) -> Path: return self.log_dir / "audit.log" diff --git a/src/voxip_kiosk/updater.py b/src/voxip_kiosk/updater.py new file mode 100644 index 0000000..1576b56 --- /dev/null +++ b/src/voxip_kiosk/updater.py @@ -0,0 +1,244 @@ +"""voxip-kiosk-updater — installs new Daynivo Kiosk OS releases (runs as root). + +A release is a tag ``vX.Y.Z`` on the Gitea repository with two assets: +``daynivo-kiosk-os-X.Y.Z.tar.gz`` and its ``.sha256``. The updater + +* checks the latest release and records the result in ``update.json`` in the + state directory (the web interface shows it), +* downloads and verifies a newer release, unpacks it under + ``/opt/daynivo-kiosk-os/`` and runs that release's + ``update.sh --no-pull`` (config backup + ``install.sh --upgrade``). + +It never installs an older or equal version. Usage:: + + voxip-kiosk-updater --check # only check + voxip-kiosk-updater --auto # check; install when auto_update is on (daily timer) + voxip-kiosk-updater --install # check and install when newer (button in the web interface) +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import logging +import re +import shutil +import ssl +import subprocess +import tarfile +import tempfile +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Callable + +from . import logutil +from .config import atomic_write_json, load_config +from .paths import Paths, default_paths, read_version + +log = logging.getLogger("voxip-kiosk-updater") + +RELEASE_API = "https://git.rothman.nl/api/v1/repos/rothman/daynivo-kiosk-os/releases/latest" +ASSET_PREFIX = "daynivo-kiosk-os-" +RELEASE_DIR = Path("/opt/daynivo-kiosk-os") +CACHE_DIR = Path("/var/cache/voxip-kiosk") +KEEP_RELEASES = 2 +MAX_DOWNLOAD = 64 * 1024 * 1024 +USER_AGENT = "Daynivo-Kiosk-OS/updater" +_VERSION = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") + + +class UpdateError(Exception): + pass + + +@dataclass(frozen=True) +class Release: + version: str + tarball_url: str + checksum_url: str + page_url: str + + +def parse_version(text: str) -> tuple[int, int, int] | None: + match = _VERSION.match(text.strip()) + return (int(match[1]), int(match[2]), int(match[3])) if match else None + + +def is_newer(candidate: str, current: str) -> bool: + new, old = parse_version(candidate), parse_version(current.removesuffix("-dev")) + return new is not None and (old is None or new > old) + + +def http_get(url: str, limit: int = MAX_DOWNLOAD, timeout: float = 30) -> bytes: + if not url.startswith("https://"): + raise UpdateError(f"Alleen HTTPS-downloads: {url}") + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/json, */*"}) + try: + with urllib.request.urlopen(request, timeout=timeout, context=ssl.create_default_context()) as response: + data = response.read(limit + 1) + except urllib.error.HTTPError as exc: + if exc.code == 404 and url == RELEASE_API: + raise UpdateError("Er is nog geen release gepubliceerd") from exc + raise UpdateError(f"Downloaden mislukt ({url}): HTTP {exc.code}") from exc + except OSError as exc: + raise UpdateError(f"Downloaden mislukt ({url}): {exc}") from exc + if len(data) > limit: + raise UpdateError(f"Download te groot: {url}") + return data + + +def parse_release(data: dict[str, Any]) -> Release: + version = str(data.get("tag_name", "")).removeprefix("v") + if parse_version(version) is None: + raise UpdateError(f"Onbekend versienummer in release: {data.get('tag_name')!r}") + assets = {a.get("name"): a.get("browser_download_url") for a in data.get("assets") or [] if isinstance(a, dict)} + tarball = assets.get(f"{ASSET_PREFIX}{version}.tar.gz") + checksum = assets.get(f"{ASSET_PREFIX}{version}.tar.gz.sha256") + if not tarball or not checksum: + raise UpdateError(f"Release {version} mist het installatiebestand of de checksum") + return Release(version, tarball, checksum, str(data.get("html_url") or "")) + + +def fetch_latest(get: Callable[[str], bytes] = http_get, api: str = RELEASE_API) -> Release: + try: + data = json.loads(get(api)) + except ValueError as exc: + raise UpdateError("Ongeldig antwoord van de releaseserver") from exc + if not isinstance(data, dict): + raise UpdateError("Ongeldig antwoord van de releaseserver") + return parse_release(data) + + +def read_state(paths: Paths) -> dict[str, Any]: + try: + data = json.loads(paths.update_state_file.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def safe_extract(archive: Path, target: Path) -> None: + """Unpack only regular files and directories that stay inside ``target``.""" + with tarfile.open(archive, "r:gz") as tar: + members = tar.getmembers() + for member in members: + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or not (member.isfile() or member.isdir()): + raise UpdateError(f"Onveilig bestand in release: {member.name}") + tar.extractall(target, members=members) # noqa: S202 - members checked above + + +class Updater: + def __init__(self, paths: Paths | None = None, get: Callable[[str], bytes] = http_get, + run: Callable[..., Any] = subprocess.run, release_dir: Path = RELEASE_DIR, + cache_dir: Path = CACHE_DIR, now: Callable[[], float] = time.time): + self.paths = paths or default_paths() + self.get = get + self.run = run + self.release_dir = release_dir + self.cache_dir = cache_dir + self.now = now + + def _write_state(self, **values: Any) -> dict[str, Any]: + state = {**read_state(self.paths), **values} + try: + atomic_write_json(self.paths.update_state_file, state, mode=0o644, owner=(0, 0)) + except PermissionError: + atomic_write_json(self.paths.update_state_file, state, mode=0o644) + except OSError as exc: + log.warning("Kon updatestatus niet opslaan: %s", exc) + return state + + def check(self) -> tuple[dict[str, Any], Release | None]: + current = read_version(self.paths) + try: + release = fetch_latest(self.get) + except UpdateError as exc: + log.warning("Controleren op updates mislukt: %s", exc) + return self._write_state(checked_at=int(self.now()), current=current, check_error=str(exc)), None + available = is_newer(release.version, current) + state = self._write_state(checked_at=int(self.now()), current=current, latest=release.version, + available=available, release_url=release.page_url, check_error=None) + if available: + log.info("Nieuwe versie beschikbaar: %s (geïnstalleerd: %s)", release.version, current) + return state, release if available else None + + def install(self, release: Release) -> dict[str, Any]: + current = read_version(self.paths) + if not is_newer(release.version, current): + raise UpdateError(f"Versie {release.version} is niet nieuwer dan {current}") + self._write_state(status="updating", target=release.version, started_at=int(self.now()), install_error=None) + log.info("Bijwerken naar %s", release.version) + try: + folder = self._download(release) + proc = self.run([str(folder / "update.sh"), "--no-pull"], capture_output=True, text=True, timeout=3600) + if proc.returncode != 0: + tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-5:] + raise UpdateError("Installatie mislukt: " + " / ".join(tail)) + except (UpdateError, OSError, subprocess.SubprocessError) as exc: + log.error("Bijwerken naar %s mislukt: %s", release.version, exc) + return self._write_state(status="failed", finished_at=int(self.now()), install_error=str(exc)) + self._prune(keep=release.version) + log.info("Bijgewerkt naar %s", release.version) + return self._write_state(status="done", finished_at=int(self.now()), current=release.version, + available=False, install_error=None) + + def _download(self, release: Release) -> Path: + tarball = self.get(release.tarball_url) + expected = self.get(release.checksum_url, limit=4096).decode("ascii", "replace").split() + actual = hashlib.sha256(tarball).hexdigest() + if not expected or expected[0].lower() != actual: + raise UpdateError("Checksum klopt niet; de download wordt niet geïnstalleerd") + + self.cache_dir.mkdir(parents=True, exist_ok=True) + self.release_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(dir=self.cache_dir) as tmp: + archive = Path(tmp) / "release.tar.gz" + archive.write_bytes(tarball) + unpacked = Path(tmp) / "unpacked" + safe_extract(archive, unpacked) + source = unpacked / f"{ASSET_PREFIX}{release.version}" + found = (source / "VERSION").read_text(encoding="utf-8").strip() if (source / "VERSION").exists() else None + if found != release.version: + raise UpdateError(f"Release bevat versie {found!r} in plaats van {release.version}") + target = self.release_dir / release.version + shutil.rmtree(target, ignore_errors=True) + shutil.move(str(source), target) + return target + + def _prune(self, keep: str) -> None: + versions = sorted((p for p in self.release_dir.iterdir() if p.is_dir() and parse_version(p.name)), + key=lambda p: parse_version(p.name), reverse=True) + for old in versions[KEEP_RELEASES:]: + if old.name != keep: + shutil.rmtree(old, ignore_errors=True) + + +def main(argv: list[str] | None = None, updater: Updater | None = None) -> int: + parser = argparse.ArgumentParser(prog="voxip-kiosk-updater", description="Daynivo Kiosk OS bijwerken.") + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true", help="alleen controleren") + mode.add_argument("--auto", action="store_true", help="controleren en bijwerken als automatisch bijwerken aan staat") + mode.add_argument("--install", action="store_true", help="controleren en een nieuwere versie installeren") + args = parser.parse_args(argv) + logutil.setup() + updater = updater or Updater() + + state, release = updater.check() + if args.check or release is None: + print(json.dumps(state, ensure_ascii=False)) + return 1 if state.get("check_error") else 0 + if args.auto: + if not load_config(updater.paths.config_file)["auto_update"]: + log.info("Automatisch bijwerken staat uit; %s wordt niet geïnstalleerd", release.version) + return 0 + if (updater.paths.health_run_dir / "maintenance").exists(): + log.info("Onderhoudsmodus actief; bijwerken overgeslagen") + return 0 + state = updater.install(release) + print(json.dumps(state, ensure_ascii=False)) + return 0 if state.get("status") == "done" else 1 diff --git a/src/voxip_kiosk/web/static/app.css b/src/voxip_kiosk/web/static/app.css index b45b91b..986ccc6 100644 --- a/src/voxip_kiosk/web/static/app.css +++ b/src/voxip_kiosk/web/static/app.css @@ -28,6 +28,7 @@ code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; fo h1 { font-size: 1.6rem; margin: 0 0 .25rem; } h2 { font-size: 1.1rem; margin: 0 0 .9rem; } .mt { margin-top: 1.6rem; } +.mt-sm { margin-top: 1rem; } .muted { color: var(--muted); } .small { font-size: .875rem; } .break { overflow-wrap: anywhere; } diff --git a/src/voxip_kiosk/web/templates/system.html b/src/voxip_kiosk/web/templates/system.html index a16fb33..6140691 100644 --- a/src/voxip_kiosk/web/templates/system.html +++ b/src/voxip_kiosk/web/templates/system.html @@ -62,6 +62,40 @@ +
+

Software-updates

+
+
Geïnstalleerd
Daynivo Kiosk OS {{ s.version }}
+
Nieuwste versie
+
+ {% if update.check_error %}Onbekend — controleren mislukt: {{ update.check_error }} + {% elif update.latest %}{{ update.latest }}{% if update.available %} — beschikbaar{% else %} (je bent bij){% endif %} + {% else %}Nog niet gecontroleerd{% endif %} +
+ {% if update.checked %}
Gecontroleerd
{{ update.checked }}
{% endif %} + {% if update.running %}
Status
Bezig met bijwerken naar {{ update.target }}…
+ {% elif update.status == 'failed' %}
Laatste update
Mislukt{% if update.finished %} op {{ update.finished }}{% endif %}: {{ update.install_error }}
+ {% elif update.status == 'done' and update.finished %}
Laatste update
Gelukt op {{ update.finished }}
{% endif %} +
+ +
+ {{ csrf() }} + + +
+ +
+
{{ csrf() }}
+ {% if update.available and not update.running %} +
+ {{ csrf() }} +
+ {% endif %} +
+

De configuratie blijft behouden en wordt voor elke update eerst bewaard in /var/backups.

+
+

Systeeminformatie

diff --git a/src/voxip_kiosk/web/views.py b/src/voxip_kiosk/web/views.py index eb6c2bf..4a9188c 100644 --- a/src/voxip_kiosk/web/views.py +++ b/src/voxip_kiosk/web/views.py @@ -13,6 +13,7 @@ from flask import (Blueprint, Response, abort, current_app, flash, g, jsonify, r from .. import netconfig, netinfo, services, sysinfo from ..agent import read_state +from ..updater import read_state as read_update_state from ..cdp import CDPError from ..config import ConfigError, load_config, save_config from ..helper_client import HelperError @@ -404,7 +405,25 @@ def system(): outputs = info.get("outputs") or [] modes = outputs[0]["modes"] if outputs else [] return render_template("system.html", active="system", conf=cfg(), modes=modes, - output=outputs[0] if outputs else None, s=collect_status()) + output=outputs[0] if outputs else None, s=collect_status(), update=update_view()) + + +def _when(timestamp: Any) -> str | None: + if not isinstance(timestamp, (int, float)): + return None + return time.strftime("%d-%m-%Y %H:%M", time.localtime(timestamp)) + + +def update_view() -> dict[str, Any]: + """The updater's last result (update.json, written by root) in display form.""" + state = read_update_state(svc().paths) + return { + **state, + "checked": _when(state.get("checked_at")), + "finished": _when(state.get("finished_at")), + # A run that started more than two hours ago and never finished is reported as failed. + "running": state.get("status") == "updating" and time.time() - state.get("started_at", 0) < 7200, + } @bp.post("/system/display") @@ -465,6 +484,34 @@ def system_admin(): return redirect(url_for("ui.system")) +@bp.post("/system/updates") +def system_updates(): + auto = _form_bool("auto_update") + if _save({"auto_update": auto}) is not None: + audit("update_settings", auto_update=auto) + flash("Automatisch bijwerken staat " + ("aan." if auto else "uit."), "success") + return redirect(url_for("ui.system") + "#updates") + + +@bp.post("/system/update/check") +def system_update_check(): + back = url_for("ui.system") + "#updates" + try: + state = svc().helper.call("update-check", timeout=60).get("update", {}) + except HelperError as exc: + return _action_response(False, str(exc), back) + if state.get("check_error"): + return _action_response(False, f"Controleren mislukt: {state['check_error']}", back) + if state.get("available"): + return _action_response(True, f"Versie {state.get('latest')} is beschikbaar.", back) + return _action_response(True, f"Je hebt de nieuwste versie ({state.get('current')}).", back) + + +@bp.post("/system/update/install") +def system_update_install(): + return _helper_action("update_install", "update-install", back=url_for("ui.system") + "#updates") + + # -- logs ------------------------------------------------------------------------ @bp.get("/logs") diff --git a/systemd/voxip-kiosk-update.service b/systemd/voxip-kiosk-update.service new file mode 100644 index 0000000..8d66f39 --- /dev/null +++ b/systemd/voxip-kiosk-update.service @@ -0,0 +1,11 @@ +[Unit] +Description=Daynivo Kiosk OS: controleren op updates (en installeren als dat aan staat) +Documentation=file:///usr/share/doc/voxip-kiosk/README.md +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/lib/voxip-kiosk/voxip-kiosk-updater --auto +TimeoutStartSec=1h +SyslogIdentifier=voxip-kiosk-updater diff --git a/systemd/voxip-kiosk-update.timer b/systemd/voxip-kiosk-update.timer new file mode 100644 index 0000000..ff0690c --- /dev/null +++ b/systemd/voxip-kiosk-update.timer @@ -0,0 +1,11 @@ +[Unit] +Description=Daynivo Kiosk OS: dagelijks controleren op updates + +[Timer] +# Night-time, spread over two hours so a fleet doesn't hit the release server at once. +OnCalendar=*-*-* 03:00 +RandomizedDelaySec=2h +Persistent=false + +[Install] +WantedBy=timers.target diff --git a/tests/test_helper.py b/tests/test_helper.py index 4b10fa7..638e16f 100644 --- a/tests/test_helper.py +++ b/tests/test_helper.py @@ -199,3 +199,20 @@ def test_boot_import_rejects_non_key_lines(helper, boot): (boot / "voxip-authorized_keys").write_text("dit is geen sleutel\n") helper.boot_import() assert (boot / "voxip-authorized_keys.rejected").exists() + + +def test_update_verbs_are_public_and_install_runs_in_its_own_unit(helper): + assert {"update-check", "update-install"} <= h.PUBLIC_VERBS + result = helper.dispatch("update-install", []) + assert "gestart" in result["message"] + command = helper.run.ran("systemd-run")[-1] + assert f"--unit={h.UPDATE_UNIT}" in command and command[-2:] == [h.UPDATER_PATH, "--install"] + + +def test_update_install_reports_a_running_update(helper): + def busy(args, timeout=30, check=True, input=None): + return subprocess.CompletedProcess(args, 1, stdout="", stderr="Unit voxip-kiosk-update-now.service already exists.") + + helper.run = busy + with pytest.raises(h.HelperError, match="al een update"): + helper.dispatch("update-install", []) diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 0000000..ba6faaf --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,198 @@ +import hashlib +import io +import json +import subprocess +import tarfile + +import pytest + +from voxip_kiosk import updater as u +from voxip_kiosk.config import save_config + +API = u.RELEASE_API +BASE = "https://git.example.nl/rothman/daynivo-kiosk-os/releases/download" + + +def make_tarball(version, files=None, top=None): + """A release archive like `git archive --prefix=daynivo-kiosk-os-/` makes.""" + top = top or f"daynivo-kiosk-os-{version}" + files = files or {"VERSION": f"{version}\n", "update.sh": "#!/bin/sh\n"} + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for name, content in files.items(): + data = content.encode() + info = tarfile.TarInfo(f"{top}/{name}" if not name.startswith(("/", "..")) else name) + info.size = len(data) + info.mode = 0o755 + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() + + +def release_json(version, assets=True): + names = [f"daynivo-kiosk-os-{version}.tar.gz", f"daynivo-kiosk-os-{version}.tar.gz.sha256"] if assets else [] + return { + "tag_name": f"v{version}", + "html_url": f"https://git.example.nl/rothman/daynivo-kiosk-os/releases/tag/v{version}", + "assets": [{"name": n, "browser_download_url": f"{BASE}/v{version}/{n}"} for n in names], + } + + +class Server: + """Stands in for the Gitea API and asset downloads.""" + + def __init__(self, version="1.2.0", tarball=None, checksum=None): + self.version = version + self.tarball = tarball if tarball is not None else make_tarball(version) + self.checksum = checksum or hashlib.sha256(self.tarball).hexdigest() + self.release = release_json(version) + self.down = False + + def __call__(self, url, limit=u.MAX_DOWNLOAD, timeout=30): + if self.down: + raise u.UpdateError("Downloaden mislukt: geen verbinding") + if url == API: + return json.dumps(self.release).encode() + if url.endswith(".tar.gz"): + return self.tarball + if url.endswith(".sha256"): + return f"{self.checksum} daynivo-kiosk-os-{self.version}.tar.gz\n".encode() + raise AssertionError(url) + + +class Run: + def __init__(self, returncode=0, stderr=""): + self.calls = [] + self.returncode = returncode + self.stderr = stderr + + def __call__(self, args, **kwargs): + self.calls.append(args) + return subprocess.CompletedProcess(args, self.returncode, stdout="", stderr=self.stderr) + + +@pytest.fixture +def make(paths, tmp_path): + (paths.share_dir / "VERSION").write_text("1.1.0\n") + + def factory(server=None, run=None): + server = server or Server() + run = run or Run() + updater = u.Updater(paths, get=server, run=run, release_dir=tmp_path / "opt", cache_dir=tmp_path / "cache", + now=lambda: 1_800_000_000) + return updater, server, run + + return factory + + +def test_versions(): + assert u.parse_version("v1.2.3") == (1, 2, 3) + assert u.parse_version("1.2") is None + assert u.is_newer("1.10.0", "1.9.9") + assert not u.is_newer("1.1.0", "1.1.0") + assert not u.is_newer("1.0.9", "1.1.0") + assert u.is_newer("1.1.0", "1.0.0-dev") + + +def test_release_needs_both_assets(): + with pytest.raises(u.UpdateError, match="mist"): + u.parse_release(release_json("1.2.0", assets=False)) + with pytest.raises(u.UpdateError, match="versienummer"): + u.parse_release({**release_json("1.2.0"), "tag_name": "nightly"}) + + +def test_check_records_an_available_update(make, paths): + updater, _, run = make() + state, release = updater.check() + assert release.version == "1.2.0" + assert state["available"] is True and state["current"] == "1.1.0" and state["latest"] == "1.2.0" + assert json.loads(paths.update_state_file.read_text())["checked_at"] == 1_800_000_000 + assert run.calls == [] + + +def test_check_when_up_to_date_or_offline(make): + updater, server, _ = make(Server(version="1.1.0")) + state, release = updater.check() + assert release is None and state["available"] is False + + server.down = True + state, release = updater.check() + assert release is None and "geen verbinding" in state["check_error"] + assert state["latest"] == "1.1.0" # the last known answer stays visible + + +def test_install_unpacks_and_runs_update_sh(make, tmp_path, paths): + updater, _, run = make() + _, release = updater.check() + state = updater.install(release) + assert state["status"] == "done" and state["current"] == "1.2.0" and state["available"] is False + folder = tmp_path / "opt" / "1.2.0" + assert run.calls == [[str(folder / "update.sh"), "--no-pull"]] + assert (folder / "VERSION").read_text().strip() == "1.2.0" + + +def test_install_refuses_a_bad_checksum(make, tmp_path): + updater, _, run = make(Server(checksum="0" * 64)) + _, release = updater.check() + state = updater.install(release) + assert state["status"] == "failed" and "Checksum" in state["install_error"] + assert run.calls == [] + assert not (tmp_path / "opt" / "1.2.0").exists() + + +def test_install_refuses_paths_outside_the_release(make): + tarball = make_tarball("1.2.0", {"VERSION": "1.2.0\n", "../../etc/evil": "x"}) + updater, _, run = make(Server(tarball=tarball)) + _, release = updater.check() + state = updater.install(release) + assert state["status"] == "failed" and "Onveilig" in state["install_error"] + assert run.calls == [] + + +def test_install_refuses_a_mislabelled_release(make): + updater, _, run = make(Server(tarball=make_tarball("1.2.0", {"VERSION": "9.0.0\n"}))) + _, release = updater.check() + assert updater.install(release)["status"] == "failed" + assert run.calls == [] + + +def test_install_reports_a_failing_installer(make): + updater, _, _ = make(run=Run(returncode=1, stderr="apt-get: kon niet verbinden")) + _, release = updater.check() + state = updater.install(release) + assert state["status"] == "failed" and "apt-get" in state["install_error"] + + +def test_never_installs_an_older_version(make): + updater, _, _ = make() + with pytest.raises(u.UpdateError): + updater.install(u.Release("1.0.0", f"{BASE}/a.tar.gz", f"{BASE}/a.sha256", "")) + + +def test_keeps_only_the_newest_releases(make, tmp_path): + updater, _, _ = make() + for old in ("1.0.0", "1.0.1", "1.1.0"): + (tmp_path / "opt" / old).mkdir(parents=True) + _, release = updater.check() + updater.install(release) + assert sorted(p.name for p in (tmp_path / "opt").iterdir()) == ["1.1.0", "1.2.0"] + + +def test_auto_mode_installs_only_when_switched_on(make, paths): + updater, _, run = make() + assert u.main(["--auto"], updater=updater) == 0 + assert run.calls == [] + + save_config(paths.config_file, {"auto_update": True}) + (paths.health_run_dir / "maintenance").touch() + assert u.main(["--auto"], updater=updater) == 0 + assert run.calls == [] + + (paths.health_run_dir / "maintenance").unlink() + assert u.main(["--auto"], updater=updater) == 0 + assert len(run.calls) == 1 + + +def test_check_mode_never_installs(make): + updater, _, run = make() + assert u.main(["--check"], updater=updater) == 0 + assert run.calls == [] diff --git a/tests/test_web.py b/tests/test_web.py index 746f427..afd5db0 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -305,3 +305,24 @@ def test_actions_menu_on_every_page(logged_in, path): html = logged_in.get(path).get_data(as_text=True) for action in ("/kiosk/reload", "/kiosk/restart", "/system/reboot", "/system/poweroff"): assert f'action="{action}"' in html + + +def test_system_page_shows_update_state(logged_in, paths): + paths.update_state_file.write_text(json.dumps({"current": "1.1.0", "latest": "1.2.0", "available": True, "checked_at": 1_800_000_000})) + body = logged_in.get("/system").get_data(as_text=True) + assert "Software-updates" in body and "Bijwerken naar 1.2.0" in body + + +def test_auto_update_setting(logged_in, paths): + logged_in.post("/system/updates", data={"csrf_token": token(logged_in), "auto_update": "1"}) + assert load_config(paths.config_file)["auto_update"] is True + logged_in.post("/system/updates", data={"csrf_token": token(logged_in)}) + assert load_config(paths.config_file)["auto_update"] is False + + +def test_update_check_and_install_go_through_the_helper(logged_in, fake_helper): + fake_helper.responses["update-check"] = {"update": {"current": "1.1.0", "latest": "1.2.0", "available": True}} + resp = logged_in.post("/system/update/check", data={"csrf_token": token(logged_in)}, follow_redirects=True) + assert "Versie 1.2.0 is beschikbaar" in resp.get_data(as_text=True) + logged_in.post("/system/update/install", data={"csrf_token": token(logged_in)}) + assert [c[0] for c in fake_helper.calls] == ["update-check", "update-install"] diff --git a/tools/release-upload.sh b/tools/release-upload.sh new file mode 100755 index 0000000..4370c22 --- /dev/null +++ b/tools/release-upload.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Attach files to the Gitea release of a tag, creating the release if needed. +# Used by .github/workflows/release.yml (Gitea Actions). +# +# GITEA_API=https://git.example.nl/api/v1/repos/owner/repo GITEA_TOKEN=… \ +# tools/release-upload.sh v1.2.3 file… +# +# An asset that already exists under the same name is replaced, so a re-run +# of the workflow (e.g. after a failed image build) is safe. + +set -euo pipefail + +TAG="${1:?tag vereist}" +shift +: "${GITEA_API:?GITEA_API vereist}" "${GITEA_TOKEN:?GITEA_TOKEN vereist}" +AUTH=(-H "Authorization: token $GITEA_TOKEN") + +if ! release="$(curl -fsS "${AUTH[@]}" "$GITEA_API/releases/tags/$TAG")"; then + version="${TAG#v}" + body="$(python3 -I -c 'import json, sys; print(json.dumps({"tag_name": sys.argv[1], "name": "Daynivo Kiosk OS " + sys.argv[2], "body": sys.argv[3]}))' \ + "$TAG" "$version" "Installeren of bijwerken: zie de README. Het SD-image volgt zodra het gebouwd is.")" + release="$(curl -fsS "${AUTH[@]}" -H "Content-Type: application/json" -d "$body" "$GITEA_API/releases")" +fi +id="$(python3 -I -c 'import json, sys; print(json.load(sys.stdin)["id"])' <<<"$release")" + +asset_id() { # id of an existing asset with this name, or nothing + python3 -I -c ' +import json, sys +release = json.load(sys.stdin) +print(next((str(a["id"]) for a in release.get("assets") or [] if a["name"] == sys.argv[1]), ""))' "$1" <<<"$release" +} + +for file in "$@"; do + name="$(basename "$file")" + old="$(asset_id "$name")" + if [[ -n $old ]]; then + curl -fsS "${AUTH[@]}" -X DELETE "$GITEA_API/releases/$id/assets/$old" >/dev/null + fi + echo "==> $name uploaden" + curl -fsS "${AUTH[@]}" -F "attachment=@$file" "$GITEA_API/releases/$id/assets?name=$name" >/dev/null +done diff --git a/uninstall.sh b/uninstall.sh index e8875bf..2690587 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -17,7 +17,7 @@ PURGE=0 UNITS=(voxip-kiosk-healthcheck.timer voxip-kiosk-healthcheck.service voxip-kiosk-agent.service voxip-kiosk-browser.service voxip-kiosk-x.service voxip-kiosk-manager.service - voxip-network-guard.service voxip-boot-import.service) + voxip-network-guard.service voxip-boot-import.service voxip-kiosk-update.timer voxip-kiosk-update.service) PREVIOUS_TARGET="$(cat /var/lib/voxip-kiosk/previous-default-target 2>/dev/null || true)" PREVIOUS_SPLASH="$(cat /var/lib/voxip-kiosk/previous-plymouth-theme 2>/dev/null || true)" @@ -57,6 +57,8 @@ fi if [[ $PURGE -eq 1 ]]; then echo "==> Configuratie, status, logs en gebruikers verwijderen" rm -rf /etc/voxip-kiosk /var/lib/voxip-kiosk /var/lib/voxip-kiosk-network /var/lib/voxip-kiosk-browser /var/log/voxip-kiosk + # Downloaded releases (install-online.sh and the updater); safe even when running from there. + rm -rf /opt/daynivo-kiosk-os /var/cache/voxip-kiosk rm -f /etc/NetworkManager/system-connections/voxip-eth0.nmconnection /etc/NetworkManager/system-connections/voxip-wlan0.nmconnection nmcli connection reload 2>/dev/null || true userdel -r kiosk 2>/dev/null || true