Private
Public Access
173 lines
6.8 KiB
Bash
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"
|