#!/usr/bin/env bash # Runs on the Pi, after rsync has completed. No secrets or device defaults. set -euo pipefail privileged=false if [[ ${1:-} == --privileged ]]; then privileged=true; shift; fi root=${1:?Basisordner fehlt} release=${2:?Version fehlt} bind_host=${3:-} [[ $root =~ ^/[a-zA-Z0-9_./-]+$ && $root != / && /$root/ != *'/../'* && /$root/ != *'/./'* ]] || exit 2 [[ $release =~ ^[a-zA-Z0-9_-]+$ ]] || exit 2 [[ -z $bind_host || $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || exit 2 candidate="$root/releases/$release" service=halloween-grab.service unit=/etc/systemd/system/$service if ! $privileged; then exec 9>"$root/.update.lock" flock -n 9 || { echo 'Ein anderes Update läuft.' >&2; exit 2; } else [[ $(id -u) == 0 && ${SUDO_USER:-} =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ && $SUDO_USER != root ]] || { echo 'Privilegierter Teil darf nur vom Upload über sudo gestartet werden.' >&2; exit 2; } fi [[ -f $candidate/run.py && ! -L $candidate ]] || exit 2 [[ ! -e $root/current || -L $root/current ]] || { echo 'current muss ein Symlink sein.' >&2; exit 2; } previous= if [[ -L $root/current ]]; then previous=$(readlink -f "$root/current") [[ -d $previous && ( $previous == "$root" || $previous == "$root/releases/"* ) ]] || { echo 'Ungültiges Ziel von current.' >&2; exit 2; } fi if [[ -z $previous && -f $root/run.py && -x $root/.venv/bin/python ]]; then previous=$root # Existing flat installation remains untouched as rollback target. fi [[ ! -L $root/data ]] || { echo 'data darf kein Symlink sein.' >&2; exit 2; } mkdir -p "$root/data" "$root/backups" chmod 700 "$root/backups" backup="$root/backups/$release" if [[ -f $root/service-host ]]; then old_host=$(cat "$root/service-host"); else old_host=127.0.0.1; fi bind_host=${bind_host:-$old_host} [[ $bind_host == 127.0.0.1 || $bind_host == 0.0.0.0 ]] || exit 2 if ! $privileged; then [[ $(id -un) != root ]] || { echo 'Update als normalen Pi-Benutzer starten.' >&2; exit 2; } # Prepare dependencies as the Pi user, before the single sudo invocation. # The parent retains the update lock while sudo and its child are running. python3 -m venv --system-site-packages "$candidate/.venv" "$candidate/.venv/bin/python" -m pip install --only-binary=:all: -r "$candidate/requirements.txt" "$candidate/.venv/bin/python" -c 'import fastapi, uvicorn, PIL, picamera2' "$candidate/.venv/bin/python" -m compileall -q "$candidate/halloween" "$candidate/run.py" sudo bash "$candidate/tools/activate_pi_release.sh" --privileged "$root" "$release" "$bind_host" exit $? fi # All systemd/root filesystem work, including rollback, runs inside this one # sudo process. No sudo timestamp, keepalive, password pipe or sudoers exception. command -v runuser >/dev/null || { echo 'runuser fehlt auf dem Pi (util-linux).' >&2; exit 2; } as_user() { runuser -u "$SUDO_USER" -- "$@" } mkdir "$backup" chown "$SUDO_UID:$SUDO_GID" "$backup" had_unit=false was_enabled=false if test -e "$unit"; then grep -qF '# Managed by Halloween project: tools/install_pi_service.sh' "$unit" || { echo 'Fremde systemd-Unit wird nicht verändert.' >&2; exit 2; } had_unit=true cat "$unit" > "$backup/service.unit" chmod 600 "$backup/service.unit" chown "$SUDO_UID:$SUDO_GID" "$backup/service.unit" if systemctl is-enabled --quiet "$service"; then was_enabled=true; fi fi install_service() { local host=$1 temporary temporary=$(mktemp) || return 1 # Render the unit and inspect user-owned project files without root privileges. if ! as_user bash "$candidate/tools/install_pi_service.sh" --project-dir "$root" --host "$host" --print-unit > "$temporary"; then rm -f "$temporary" return 1 fi if ! install -m 0644 "$temporary" "$unit"; then rm -f "$temporary" return 1 fi rm -f "$temporary" || return 1 systemctl daemon-reload && systemctl enable "$service" && systemctl restart "$service" && systemctl is-active --quiet "$service" } switch_link() { ln -s "$1" "$root/.current-$release" mv -Tf "$root/.current-$release" "$root/current" && chown -h "$SUDO_UID:$SUDO_GID" "$root/current" } check_health() { as_user "$candidate/.venv/bin/python" - <<'PY' || return 1 import json, time, urllib.request for attempt in range(30): try: with urllib.request.urlopen('http://127.0.0.1:8000/api/status', timeout=1) as response: value = json.load(response) if isinstance(value, dict) and isinstance(value.get('controller'), dict) and isinstance(value.get('camera'), dict): break except Exception: pass time.sleep(1) else: raise SystemExit('WebUI antwortet nicht innerhalb der Startfrist.') PY systemctl is-active --quiet "$service" } transaction=false data_saved=false rollback() { status=$? trap - EXIT INT TERM if $transaction; then set +e echo 'Update fehlgeschlagen: vorherigen Stand wiederherstellen.' >&2 rollback_ok=true if ! systemctl stop "$service"; then echo "Dienst lässt sich nicht stoppen; Daten nicht zurückgespielt. Sicherung: $backup" >&2 exit 1 fi if $data_saved; then mv "$root/data" "$backup/failed-data" && cp -a "$backup/data" "$root/data" || rollback_ok=false fi if [[ -n $previous ]]; then switch_link "$previous" || rollback_ok=false; else rm -f "$root/current"; fi printf '%s\n' "$old_host" > "$root/service-host" && chown "$SUDO_UID:$SUDO_GID" "$root/service-host" || rollback_ok=false if $had_unit; then install -m 0644 "$backup/service.unit" "$unit" || rollback_ok=false else systemctl disable "$service" || rollback_ok=false rm -f "$unit" || rollback_ok=false fi systemctl daemon-reload || rollback_ok=false if $had_unit; then if ! $was_enabled; then systemctl disable "$service" || rollback_ok=false; fi systemctl restart "$service" && check_health || rollback_ok=false elif [[ -n $previous ]]; then install_service "$old_host" && check_health || rollback_ok=false fi if $rollback_ok; then echo 'Rückfall abgeschlossen.' >&2; else echo "Rückfall unvollständig! Dienst und Sicherung $backup prüfen." >&2; fi exit 1 fi exit "$status" } trap rollback EXIT trap 'exit 130' INT trap 'exit 143' TERM if $had_unit; then transaction=true; systemctl stop "$service"; fi # Refuse an unmanaged server, rather than claiming its response as update success. as_user "$candidate/.venv/bin/python" - <<'PY' import socket with socket.socket() as sock: if sock.connect_ex(('127.0.0.1', 8000)) == 0: raise SystemExit('Port 8000 ist noch belegt. Manuell gestartete WebUI zuerst beenden.') PY cp -a "$root/data" "$backup/data" data_saved=true transaction=true switch_link "$candidate" printf '%s\n' "$bind_host" > "$root/service-host" chown "$SUDO_UID:$SUDO_GID" "$root/service-host" install_service "$bind_host" check_health transaction=false printf 'Version %s aktiv; WebUI-Autostart eingeschaltet. Sicherung: %s\n' "$release" "$backup"