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 <noreply@anthropic.com>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-05 19:30:34 +01:00
1 parent 33ef898fcc
commit f8303754f7
25 files changed
+899 -20

No files matched your search

+8 -3
View File
@@ -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
+77
View File
@@ -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
# <date>-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
+1 -1
View File
@@ -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:
+60 -4
View File
@@ -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 <url-van-deze-repository> 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/<versie>` 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/<versie>` (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-<versie>.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://<hostname>.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
+1 -1
View File
@@ -1 +1 @@
1.0.0
1.1.0
+9
View File
@@ -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())
+2 -1
View File
@@ -11,5 +11,6 @@
"browser_profile": "persistent",
"admin_port": 8080,
"admin_https": false,
"admin_allow_public": false
"admin_allow_public": false,
"auto_update": false
}
+7 -3
View File
@@ -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/<versie>` 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)
+8
View File
@@ -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
+63
View File
@@ -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/<version> 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 "$@"
+5 -4
View File
@@ -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
+2
View File
@@ -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,
}
+22 -1
View File
@@ -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}")
+4
View File
@@ -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"
+244
View File
@@ -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/<version>`` 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
+1
View File
@@ -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; }
+34
View File
@@ -62,6 +62,40 @@
</section>
</div>
<section class="card" id="updates">
<h2>Software-updates</h2>
<dl class="kv">
<dt>Geïnstalleerd</dt><dd>Daynivo Kiosk OS {{ s.version }}</dd>
<dt>Nieuwste versie</dt>
<dd>
{% if update.check_error %}<span class="muted">Onbekend — controleren mislukt: {{ update.check_error }}</span>
{% elif update.latest %}{{ update.latest }}{% if update.available %} <strong>— beschikbaar</strong>{% else %} <span class="muted">(je bent bij)</span>{% endif %}
{% else %}<span class="muted">Nog niet gecontroleerd</span>{% endif %}
</dd>
{% if update.checked %}<dt>Gecontroleerd</dt><dd>{{ update.checked }}</dd>{% endif %}
{% if update.running %}<dt>Status</dt><dd>Bezig met bijwerken naar {{ update.target }}…</dd>
{% elif update.status == 'failed' %}<dt>Laatste update</dt><dd>Mislukt{% if update.finished %} op {{ update.finished }}{% endif %}: {{ update.install_error }}</dd>
{% elif update.status == 'done' and update.finished %}<dt>Laatste update</dt><dd>Gelukt op {{ update.finished }}</dd>{% endif %}
</dl>
<form method="post" action="{{ url_for('ui.system_updates') }}" class="form mt-sm">
{{ csrf() }}
<label class="check"><input type="checkbox" name="auto_update" value="1" {% if conf.auto_update %}checked{% endif %}> Automatisch bijwerken <span class="muted">(elke nacht tussen 03:00 en 05:00, als er een nieuwe versie is)</span></label>
<button type="submit" class="btn btn-secondary">Opslaan</button>
</form>
<div class="actions-row mt-sm">
<form method="post" action="{{ url_for('ui.system_update_check') }}" class="inline-form">{{ csrf() }}<button type="submit" class="btn btn-secondary">Nu controleren</button></form>
{% if update.available and not update.running %}
<form method="post" action="{{ url_for('ui.system_update_install') }}" class="inline-form"
data-confirm="Daynivo Kiosk OS wordt bijgewerkt naar {{ update.latest }}. Het scherm en de beheerinterface zijn een paar minuten niet beschikbaar." data-confirm-title="Nu bijwerken">
{{ csrf() }}<button type="submit" class="btn btn-primary">Bijwerken naar {{ update.latest }}</button>
</form>
{% endif %}
</div>
<p class="muted small">De configuratie blijft behouden en wordt voor elke update eerst bewaard in <code>/var/backups</code>.</p>
</section>
<section class="card">
<h2>Systeeminformatie</h2>
<dl class="kv">
+48 -1
View File
@@ -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")
+11
View File
@@ -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
+11
View File
@@ -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
+17
View File
@@ -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", [])
+198
View File
@@ -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-<v>/` 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 == []
+21
View File
@@ -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"]
+42
View File
@@ -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
+3 -1
View File
@@ -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