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:
1 parent
33ef898fcc
commit
f8303754f7
25 files changed
+899
-20
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+9
@@ -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())
|
||||
@@ -11,5 +11,6 @@
|
||||
"browser_profile": "persistent",
|
||||
"admin_port": 8080,
|
||||
"admin_https": false,
|
||||
"admin_allow_public": false
|
||||
"admin_allow_public": false,
|
||||
"auto_update": false
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+63
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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", [])
|
||||
@@ -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 == []
|
||||
@@ -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"]
|
||||
Executable
+42
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user