Private
Public Access
addet test set
This commit is contained in:
1 parent
d370f992ce
commit
b0f35bf3cc
21 files changed
+1796
-183
No files matched your search
@@ -0,0 +1,172 @@
|
||||
#!/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"
|
||||
Reference in new issue
Block a user