deploy/pi_switch.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Schaltschritt auf dem Zielrechner (Pi): neues Image in Betrieb nehmen, Rückweg sichern,
# prüfen, notfalls zurückschalten. Wird von scripts/deploy_pi.sh per ssh aufgerufen und
# liegt deshalb im deploy/-Ordner, der ohnehin mitgeschickt wird. Eigene Datei, damit sich
# der Ablauf mit einem nachgebauten `docker` testen lässt (backend/tests/test_deploy_switch.py).
#
# ./pi_switch.sh switch <image-tag> # prüfen, sichern, compose up, verify
# ./pi_switch.sh verify # läuft der neue Container, mit dem neuen Image und dem alten Volume?
# ./pi_switch.sh cleanup # erst NACH verify, Health und Datencheck: alte Images entfernen
# ./pi_switch.sh rollback # zurück auf den alten Stand, prüfen
# ./pi_switch.sh precheck # Stand VOR dem Umschalten: läuft ein Dashboard, ist es eingerichtet?
# ./pi_switch.sh bootstrap [versuche] # needed=false|needed=true|http=<code>|noanswer (GET /api/v1/auth/bootstrap)
# ./pi_switch.sh loaded [sekunden] # none|invalid|list=<kennungen>: welche Erweiterungen hat das laufende Dashboard geladen?
# ./pi_switch.sh detached <unterbefehl> ... # wie der Unterbefehl, aber von der ssh-Leitung abgekoppelt
# ./pi_switch.sh oldname # nur: liegt hier noch ein Stand von vor 0.6? (Exit 3 = ja)
#
# Exit 3 = es wurde nichts verändert (Docker nicht erreichbar, Image fehlt, Compose-Datei
# nicht lesbar, Stand von vor 0.6); der Aufrufer darf dann NICHT zurückschalten.
# Exit 4 = der Container hat das richtige Volume, läuft aber nicht (mehr) oder hängt in der
# Neustart-Schleife. War er vorher schon gesund, kann er die Datenbank bereits umgebaut haben.
#
# Die Namen sind überschreibbar (Tests), Standard ist der echte Betrieb:
# DECK_IMAGE=nodvard-deck CONTAINER=deploy-nodvard-deck-1
# PROJECT=deploy VOLUME=deploy_lattice_data
#
# Das Volume (`deploy_lattice_data`) bleibt immer unberührt; `verify` prüft, dass der neue
# Container wirklich dieses Volume unter /app/data eingebunden hat (sonst wäre es leer).
# Bewusst KEIN `--remove-orphans`: das wirkt auf das ganze Projekt und löscht Container, die
# nicht zu diesem Dienst gehören.
set -euo pipefail
cd "$(dirname "$0")"
# Bricht die ssh-Verbindung mitten im Umschalten ab (WLAN, Zeitüberschreitung), bekommt die
# Shell ein SIGHUP. Das hier schützt nur die Shell selbst (und wird von den docker-Aufrufen
# geerbt), NICHT vor einem geschlossenen Ausgabe-Rohr (SIGPIPE, `printf` scheitert, docker
# stirbt). Dafür gibt es `detached`: Ausgabe in Dateien, eigene Sitzung (setsid).
trap '' HUP
SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
DECK_IMAGE="${DECK_IMAGE:-nodvard-deck}"
CONTAINER="${CONTAINER:-deploy-nodvard-deck-1}"
PROJECT="${PROJECT:-deploy}"
VOLUME="${VOLUME:-deploy_lattice_data}"
# So hieß der Container vor Version 0.6. Den Umstieg von dort liefert nur noch Version 0.11 aus.
PRE_RENAME_CONTAINER="deploy-lattice-1"
# Compose liest seine Variablen aus der .env neben der Compose-Datei; dort gelten die alten Namen LATTICE_* nur noch
# in dieser Version.
DEPLOY_ENV_FILE="${DEPLOY_ENV_FILE:-.env}"
say() { printf '%s\n' "$*"; }
die() { printf 'FAIL: %s\n' "$*" >&2; exit 1; }
die_unchanged() { printf 'FAIL: %s\n' "$*" >&2; exit 3; }
# Projektname immer mitgeben: COMPOSE_PROJECT_NAME in der Umgebung oder in .env würde sonst
# ein anderes Projekt (und damit ein anderes, leeres Volume) wählen.
compose() { docker compose -p "$PROJECT" "$@"; }
has_image() { docker image inspect "$1" >/dev/null 2>&1; }
has_container() { docker container inspect "$1" >/dev/null 2>&1; }
image_id() { docker image inspect -f '{{.Id}}' "$1" 2>/dev/null; }
inspect_container() { docker container inspect -f "$2" "$1"; }
need_docker() {
docker info >/dev/null 2>&1 || die_unchanged "Docker ist nicht erreichbar (laeuft der Dienst? Hat dieser Benutzer Docker-Rechte?). Nichts veraendert."
}
# Läuft der Container gerade (und belegt dann z. B. Port 8080)?
container_running() {
has_container "$1" && [ "$(inspect_container "$1" '{{.State.Status}}')" = "running" ]
}
# Rückweg sichern: nodvard-deck:latest -> nodvard-deck:previous. Wurde dasselbe Image schon in
# Betrieb genommen (erneuter Aufruf nach einem Abbruch), bleibt der Rückweg, wie er ist, damit er
# nicht mit dem fraglichen Stand überschrieben wird.
backup_previous() {
local new_id="$1" cur_id
if has_image "$DECK_IMAGE:latest"; then
cur_id="$(image_id "$DECK_IMAGE:latest")"
if [ "$cur_id" = "$new_id" ] && has_image "$DECK_IMAGE:previous"; then
say "Sicherung: $DECK_IMAGE:latest ist schon das neue Image, $DECK_IMAGE:previous bleibt."
else
docker tag "$DECK_IMAGE:latest" "$DECK_IMAGE:previous"
say "Sicherung: $DECK_IMAGE:latest -> $DECK_IMAGE:previous."
fi
else
say "Frische Maschine: kein altes Image, keine Sicherung moeglich."
fi
}
# Läuft der Container wirklich (Status running, nicht im Neustart-Kreislauf) und hängt
# /app/data am Volume mit den alten Daten?
check_container() {
local name="$1" status restarting vol
status="$(inspect_container "$name" '{{.State.Status}}')"
restarting="$(inspect_container "$name" '{{.State.Restarting}}')"
vol="$(inspect_container "$name" '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Name}}{{end}}{{end}}')"
[ "$vol" = "$VOLUME" ] || die "Container $name hat unter /app/data das Volume '${vol:-<keins>}' statt $VOLUME -- die Daten waeren weg (leeres Volume)."
if [ "$status" != "running" ] || [ "$restarting" != "false" ]; then
printf 'FAIL: %s\n' "Container $name laeuft nicht (Status: $status, Neustart-Schleife: $restarting)." >&2
exit 4
fi
}
# Trägt der neue Container das Image von nodvard-deck:latest, läuft er, hat er das richtige
# Volume? Ein "gesund" von /api/v1/health allein beweist das nicht.
verify() {
local want have
want="$(image_id "$DECK_IMAGE:latest")" || die "Image $DECK_IMAGE:latest fehlt."
has_container "$CONTAINER" || die "Container $CONTAINER existiert nicht."
have="$(inspect_container "$CONTAINER" '{{.Image}}')"
[ "$have" = "$want" ] || die "Container $CONTAINER laeuft mit einem anderen Image als $DECK_IMAGE:latest."
check_container "$CONTAINER"
say "VERIFY-OK: $CONTAINER laeuft mit $DECK_IMAGE:latest ($want), Volume $VOLUME."
}
# Gibt es den Container von vor 0.6 noch, bricht das vor jeder Änderung ab (Exit 3): Er hätte
# dasselbe Volume offen, und sein Image wäre kein Rückweg, den `rollback` kennt.
refuse_pre_rename() {
has_container "$PRE_RENAME_CONTAINER" || return 0
if container_running "$PRE_RENAME_CONTAINER" || ! has_image "$DECK_IMAGE:latest"; then
die_unchanged "Hier liegt noch Nodvard Deck von vor Version 0.6 (Container $PRE_RENAME_CONTAINER). Den Umstieg liefert nur Version 0.11 aus: git checkout v0.11.1, scripts/deploy_pi.sh, danach diese Version. Nichts veraendert."
fi
die_unchanged "Der alte Container $PRE_RENAME_CONTAINER liegt noch gestoppt auf dem Rechner. Entfernen mit 'docker rm $PRE_RENAME_CONTAINER', dann erneut ausliefern. Nichts veraendert."
}
# Alte Namen in der .env auf die neuen umbenennen, die Datei vorher als Kopie behalten. Hat der neue Name schon einen
# Wert, fällt die alte Zeile weg; ein leerer neuer weicht dem alten Wert, wie bei `${NEU:-${ALT:-}}` in Compose.
rename_old_env() {
[ -f "$DEPLOY_ENV_FILE" ] || return 0
if grep -q '^LATTICE_[A-Za-z0-9_]*=' "$DEPLOY_ENV_FILE"; then
local copy="$DEPLOY_ENV_FILE.vor-umbenennung" next="$DEPLOY_ENV_FILE.neu"
cp -p "$DEPLOY_ENV_FILE" "$copy" || die_unchanged "Kopie von $DEPLOY_ENV_FILE ging nicht. Nichts veraendert."
# umask 077: Die Zwischendatei enthält dieselben Werte wie die .env und soll nicht kurz für alle lesbar sein.
if ! (umask 077 && awk -v q="'" '
function key(line) { sub(/=.*/, "", line); return line }
function filled(line, v) {
v = substr(line, index(line, "=") + 1); sub(/^[ \t]+/, "", v); sub(/[ \t\r]+$/, "", v)
return v != "" && v != "\"\"" && v != q q
}
NR == FNR {
if ($0 ~ /^NODVARD_DECK_[A-Za-z0-9_]*=/ && filled($0)) set[key($0)] = 1
if ($0 ~ /^LATTICE_[A-Za-z0-9_]*=/) old["NODVARD_DECK_" substr(key($0), 9)] = 1
next
}
/^NODVARD_DECK_[A-Za-z0-9_]*=/ && !filled($0) && (key($0) in old) && !(key($0) in set) { next }
/^LATTICE_[A-Za-z0-9_]*=/ { line = "NODVARD_DECK_" substr($0, 9); if (!(key(line) in set)) print line; next }
{ print }' "$copy" "$copy" > "$next") || ! cat "$next" > "$DEPLOY_ENV_FILE"; then
rm -f "$next"
die_unchanged "$DEPLOY_ENV_FILE liess sich nicht umbenennen (vorher: $copy). Nichts veraendert."
fi
rm -f "$next"
say "$DEPLOY_ENV_FILE: alte Namen LATTICE_* auf NODVARD_DECK_* umbenannt (vorher: $copy)."
fi
if grep -q '^[^#]*LATTICE_' "$DEPLOY_ENV_FILE"; then
say "WARNUNG: In $DEPLOY_ENV_FILE stehen noch Zeilen mit LATTICE_, die nicht automatisch umbenannt werden konnten. Bitte von Hand auf NODVARD_DECK_ umstellen, ab Version 1.0 gelten sie nicht mehr."
fi
}
cmd_switch() {
local new_tag="${1:?Nutzung: pi_switch.sh switch <image-tag>}" new_id
# Alles, was schieflaufen kann, BEVOR etwas angefasst wird (Exit 3 = nichts verändert).
need_docker
refuse_pre_rename
has_image "$new_tag" || die_unchanged "Image $new_tag ist auf diesem Rechner nicht vorhanden."
compose config -q >/dev/null 2>&1 || die_unchanged "Die Compose-Datei ist nicht lesbar (docker compose config scheitert; zu alte Compose-Version?). Nichts veraendert."
rename_old_env
new_id="$(image_id "$new_tag")"
backup_previous "$new_id"
docker tag "$new_tag" "$DECK_IMAGE:latest"
compose up -d --no-build
verify
}
# Erst aufrufen, wenn verify, Health und Datencheck bestanden sind: alte Images entfernen. Das
# Volume wird nie angefasst.
cmd_cleanup() {
need_docker
verify
local latest_id prev_id="" tag
latest_id="$(image_id "$DECK_IMAGE:latest")"
if has_image "$DECK_IMAGE:previous"; then prev_id="$(image_id "$DECK_IMAGE:previous")"; fi
# Gelieferte Tags nodvard-deck:pi-<sha>, außer den Ständen hinter latest/previous.
for tag in $(docker images "$DECK_IMAGE" --format '{{.Tag}}' 2>/dev/null); do
case "$tag" in
pi-*)
if [ "$(image_id "$DECK_IMAGE:$tag")" != "$latest_id" ] && [ "$(image_id "$DECK_IMAGE:$tag")" != "$prev_id" ]; then
if docker rmi "$DECK_IMAGE:$tag" >/dev/null 2>&1; then
say "Altes Image $DECK_IMAGE:$tag entfernt."
else
say "Hinweis: $DECK_IMAGE:$tag liess sich nicht entfernen."
fi
fi
;;
esac
done
say "CLEANUP-OK"
}
cmd_rollback() {
need_docker
if has_image "$DECK_IMAGE:previous"; then
docker tag "$DECK_IMAGE:previous" "$DECK_IMAGE:latest"
say "Rollback: $DECK_IMAGE:previous -> $DECK_IMAGE:latest."
fi
has_image "$DECK_IMAGE:previous" || die "Kein $DECK_IMAGE:previous vorhanden, nichts zum Zurueckschalten."
compose up -d --no-build
verify
}
# Stand vor dem Umschalten. Gibt genau eine Zeile aus:
# none kein Dashboard-Container läuft (frische Maschine oder aus)
# needed=false läuft und ist eingerichtet
# needed=true läuft, aber noch nicht eingerichtet
# http=<code> läuft und antwortet, kennt den Endpunkt aber nicht (z. B. 404 beim alten Image)
# noanswer ein Container läuft, antwortet aber auch nach mehreren Versuchen nicht
cmd_precheck() {
need_docker
refuse_pre_rename
if ! container_running "$CONTAINER"; then say "none"; return 0; fi
local answer
answer="$(cmd_bootstrap 5)"
case "$answer" in
http=*)
# Ohne eine Antwort vom Endpunkt zählt als "eingerichtet" nur, wer wirklich das alte Volume hat.
if [ "$(inspect_container "$CONTAINER" '{{range .Mounts}}{{if eq .Destination "/app/data"}}{{.Name}}{{end}}{{end}}')" = "$VOLUME" ]; then
say "$answer"
else
say "noanswer"
fi
;;
*) say "$answer" ;;
esac
}
# GET /api/v1/auth/bootstrap, bis zu $1 Versuche (Standard 1) mit Pause BOOTSTRAP_RETRY_SLEEP_S.
cmd_bootstrap() {
local tries="${1:-1}" i out code body
for i in $(seq 1 "$tries"); do
out="$(curl -s -m 3 -w '\n%{http_code}' http://127.0.0.1:8080/api/v1/auth/bootstrap 2>/dev/null || true)"
code="${out##*$'\n'}"
body="${out%$'\n'*}"
if [ -n "$code" ] && [ "$code" != "000" ]; then
if [ "$code" = "200" ] && printf '%s' "$body" | grep -Eq '"needed": ?false'; then say "needed=false"; return 0; fi
if [ "$code" = "200" ] && printf '%s' "$body" | grep -Eq '"needed": ?true'; then say "needed=true"; return 0; fi
say "http=$code"; return 0
fi
[ "$i" -lt "$tries" ] && sleep "${BOOTSTRAP_RETRY_SLEEP_S:-3}"
done
say "noanswer"
}
# Welche Erweiterungen hat das laufende Dashboard geladen? Jeder Start und jedes Ein- oder Ausschalten danach schreibt
# eine Zeile
# Erweiterungen geladen: <Speicher-Kennungen, sortiert, durch Komma getrennt> oder ... (keine)
# ins Protokoll (backend/src/nodvard_deck/services/extensions.py, `_announce_loaded`; ein Test hält den Text
# gleich), die letzte sagt, was gerade läuft. scripts/deploy_pi.sh vergleicht die Zeile des alten mit der des neuen Containers: Eine Erweiterung,
# die vorher geladen war und nachher fehlt, gilt als Fehler -- auch wenn nichts sonst meldet, dass sie nicht
# läuft (z. B. die Zeile der Erweiterung bleibt "eingeschaltet", der Dienst findet sie aber nicht mehr).
LOADED_MARKER='Erweiterungen geladen: '
# Liest ein Protokoll von stdin, nimmt die LETZTE Zeile mit dem Text (ein Neustart oder ein Ein-/Ausschalten schreibt
# eine neue) und gibt aus:
# none keine solche Zeile
# invalid die Zeile ist nicht lesbar (keine gültige Liste hinter dem Text)
# list=<a,b,c> die Kennungen; `list=` ohne etwas dahinter: es war keine Erweiterung geladen ("(keine)")
# Vor der Zeile darf alles stehen (Zeitstempel, Ebene, Farbcodes). Dahinter muss genau die Liste folgen: Kennungen
# (a-z, 0-9, Bindestrich; Anfang a-z), durch Kommas getrennt, danach Ende der Zeile, Leerraum oder ein Anführungs-/
# Klammerzeichen (falls einmal als JSON geschrieben). Alles andere ist `invalid`, nie eine verkürzte Liste. Nichts davon
# wird je als Befehl oder Muster ausgewertet, es wird nur verglichen.
loaded_from_log() {
local LC_ALL=C line rest esc re_none re_list
line="$(grep -a -F -- "$LOADED_MARKER" | tail -n 1)" || true
if [ -z "$line" ]; then say none; return 0; fi
esc="$(printf '\033')"
line="$(printf '%s' "$line" | sed -e "s/${esc}\\[[0-9;]*[A-Za-z]//g")" || true
rest="${line##*"$LOADED_MARKER"}"
re_none='^\(keine\)([]}"[:space:]]|$)'
re_list='^([a-z][a-z0-9-]*(,[a-z][a-z0-9-]*)*)([]}"[:space:]]|$)'
if [[ "$rest" =~ $re_none ]]; then
say "list="
elif [[ "$rest" =~ $re_list ]]; then
say "list=${BASH_REMATCH[1]}"
else
say invalid
fi
}
# `loaded [sekunden]`: Antwort für das gerade laufende Dashboard, ohne etwas zu verändern. Läuft keins, oder steht
# noch keine Zeile im Protokoll, wird bis zu `sekunden` gewartet (Standard 0 = ein Versuch; die Zeile steht vor dem
# ersten Health-Ok im Protokoll, das Warten fängt nur die Verzögerung des Docker-Protokolls ab), danach `none`. Das
# ganze Protokoll wird gelesen (Docker begrenzt es auf 3 x 10 MB): Der Start liegt oft weit zurück, und jede Anfrage
# steht auch darin.
cmd_loaded() {
need_docker
local secs="${1:-0}" answer deadline
case "$secs" in ''|*[!0-9]*) echo "Nutzung: $0 loaded [sekunden]" >&2; exit 2 ;; esac
if ! container_running "$CONTAINER"; then say none; return 0; fi
deadline=$((SECONDS + secs))
while :; do
answer="$(docker logs "$CONTAINER" 2>&1 | loaded_from_log || true)"
[ "$answer" = none ] || break
[ "$SECONDS" -lt "$deadline" ] || break
container_running "$CONTAINER" || break
sleep "${LOADED_POLL_SLEEP_S:-2}"
done
say "${answer:-none}"
}
# Wie der Unterbefehl, aber von der ssh-Leitung abgekoppelt: eigene Sitzung (setsid), Ein-/Ausgabe
# in Dateien. Reisst die Verbindung ab, läuft der Schaltschritt auf dem Pi zu Ende (Ergebnis in
# pi_switch.out/.err/.log). Bleibt die Verbindung, wird die Ausgabe am Ende ausgegeben.
cmd_detached() {
local rc=0 dir="${PI_SWITCH_LOG_DIR:-.}"
mkdir -p "$dir"
: > "$dir/pi_switch.out"; : > "$dir/pi_switch.err"
if command -v setsid >/dev/null 2>&1; then
setsid -w bash "$SELF" "$@" >"$dir/pi_switch.out" 2>"$dir/pi_switch.err" </dev/null || rc=$?
else
bash "$SELF" "$@" >"$dir/pi_switch.out" 2>"$dir/pi_switch.err" </dev/null || rc=$?
fi
cat "$dir/pi_switch.out"
cat "$dir/pi_switch.err" >&2
return "$rc"
}
case "${1:-}" in
detached) shift; cmd_detached "$@" ;;
precheck) cmd_precheck ;;
bootstrap) shift; cmd_bootstrap "$@" ;;
loaded) shift; cmd_loaded "$@" ;;
switch) shift; cmd_switch "$@" ;;
verify) need_docker; verify ;;
oldname) need_docker; refuse_pre_rename ;;
cleanup) cmd_cleanup ;;
rollback) cmd_rollback ;;
*) echo "Nutzung: $0 switch <image-tag> | verify | cleanup | rollback | precheck | bootstrap | loaded" >&2; exit 2 ;;
esac