Files
Halloween/tools/activate_pi_release.sh
T
2026-10-14 17:40:31 +02:00

173 lines
6.8 KiB
Bash

#!/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"