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

+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")