Ctrl K
ComunidadGuías…

scripts/deploy_pi.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# Nodvard Deck auf einen Raspberry Pi (oder einen anderen Linux-Server) ausliefern, OHNE dort
# zu bauen.
#
# Warum: Der Pi hat 3,7 GB RAM. Ein Docker-Build dort treibt die Last auf 18, lässt
# den Container über 90 s lang starten und hat schon zu falschen "DEPLOY-FAIL"-
# Meldungen geführt. Das Image wird deshalb hier auf dem PC gebaut (Docker Desktop
# mit QEMU für linux/arm64; die Frontend-Stufe läuft nativ, siehe deploy/Dockerfile
# `FROM --platform=$BUILDPLATFORM`) und fertig auf den Pi kopiert.
#
# Aufruf aus dem Repository-Root (Git Bash):
#   scripts/deploy_pi.sh                 # HEAD bauen, ausliefern, prüfen
#   scripts/deploy_pi.sh --no-build      # zuletzt gebautes Image erneut ausliefern
#   PI_HOST=admin@192.168.1.50 scripts/deploy_pi.sh
#
# Zielrechner und Optionen: als Umgebungsvariablen ODER (bequemer) einmalig in der
# lokalen, git-ignorierten Datei deploy/.env.deploy (Vorlage: deploy/.env.deploy.example):
#   PI_HOST=benutzer@adresse      # SSH-Ziel, Pflicht (ohne bricht das Skript ab)
#   DEPLOY_ROOT=lattice-deploy-test
#   NODVARD_DECK_DNS_1=... NODVARD_DECK_DNS_2=...   # optional: Resolver für den Container,
#                                         # wird als deploy/.env auf dem Ziel abgelegt.
#                                         # Die alten Namen LATTICE_DNS_1/2 gelten nur noch
#                                         # übergangsweise, spätestens bis 1.0; sind beide
#                                         # gesetzt, gewinnt der neue.
#
# Voraussetzung: Die komplette Test-Suite ist grün (siehe deploy/README.md). Das
# Skript prüft das NICHT selbst, es dauert ~6 Minuten und gehört vor den Aufruf.
#
# Ablauf: bauen -> docker save | gzip | ssh 'gunzip | docker load' -> auf dem Pi
# `deploy/pi_switch.sh switch` (vorab: Docker erreichbar? Compose-Datei lesbar? Kein Stand von vor 0.6?
# Dann altes Image als nodvard-deck:previous sichern; compose up --no-build; prüfen, dass der neue Container
# mit dem neuen Image und dem alten Volume läuft) -> bis zu 4 Minuten auf /api/v1/health
# warten -> Image/Volume-Prüfung wiederholen -> Datencheck (/api/v1/auth/bootstrap muss
# needed=false bleiben, wenn es vorher so war) -> Erweiterungen vergleichen (jeder Start und jedes Ein- oder Ausschalten
# schreibt die Zeile "Erweiterungen geladen: <Kennungen>" ins Protokoll, die letzte zählt; fehlt nachher eine
# Erweiterung, die vorher geladen war, ist das ein Fehler wie ein gescheiterter Datencheck, auch wenn sonst nichts
# meldet, dass sie nicht läuft; kennt der alte Stand die Zeile noch nicht, gibt es nur einen Hinweis) -> Logs auf
# Tracebacks prüfen. Scheitert der Start des neuen Images
# (Migration), antwortet statt der Anwendung die Notseite mit 503 `{"status":"rescue"}`: nie "ok", also Rollback
# (und ohne die volle Wartezeit). Schlägt etwas
# fehl, wird automatisch zurückgeschaltet (`pi_switch.sh rollback`) und geprüft, dass der
# alte Stand wieder antwortet, sonst ROLLBACK-FAIL. Ausnahme: Hat der neue Container die Datenbank schon
# umgebaut und war gestartet (oder lässt sich das nicht feststellen), gibt es KEINEN automatischen
# Rollback (das alte Image würde die Daten ablehnen), nur DEPLOY-FAIL mit Hinweisen. Erst wenn alles stimmt, räumt
# `pi_switch.sh cleanup` alte Images weg. Rückfallweg (tar + Build auf dem Pi): deploy/README.md.
set -euo pipefail

# Lokale Einstellungen (git-ignoriert) laden; bereits gesetzte Umgebungsvariablen
# haben Vorrang vor der Datei.
_ENV_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/deploy/.env.deploy"
if [ -f "$_ENV_FILE" ]; then
  while IFS='=' read -r _key _val || [ -n "$_key" ]; do
    _val="${_val%$'\r'}"
    case "$_key" in ''|\#*) continue ;; esac
    _val="${_val%\"}"; _val="${_val#\"}"
    [ -z "${!_key+x}" ] && export "$_key=$_val"
  done < "$_ENV_FILE"
fi

if [ -z "${PI_HOST:-}" ]; then
  echo "FEHLER: PI_HOST ist nicht gesetzt - wohin soll deployt werden?" >&2
  echo "  Einmalig anlegen: deploy/.env.deploy mit der Zeile PI_HOST=benutzer@adresse" >&2
  echo "  (Vorlage: deploy/.env.deploy.example) oder: PI_HOST=benutzer@adresse scripts/deploy_pi.sh" >&2
  exit 1
fi
DEPLOY_ROOT="${DEPLOY_ROOT:-lattice-deploy-test}"   # relativ zum Home des Pi-Nutzers
COMPOSE_DIR="$DEPLOY_ROOT/deploy"
CONTAINER="${CONTAINER:-deploy-nodvard-deck-1}"
HEALTH_TIMEOUT_S="${HEALTH_TIMEOUT_S:-240}"
ROLLBACK_HEALTH_TIMEOUT_S="${ROLLBACK_HEALTH_TIMEOUT_S:-120}"
LOADED_WAIT_S="${LOADED_WAIT_S:-30}"   # so lange höchstens auf die Zeile "Erweiterungen geladen" des neuen Stands warten
BUILD=1
for arg in "$@"; do
  case "$arg" in
    --no-build) BUILD=0 ;;
    -h|--help) sed -n '2,/^set -euo/{/^set -euo/!p;}' "$0"; exit 0 ;;
    *) echo "Unbekannte Option: $arg" >&2; exit 2 ;;
  esac
done

cd "$(git rev-parse --show-toplevel)"
SHA="$(git rev-parse --short HEAD)"
LOCAL_TAG="nodvard-deck:pi-$SHA"
SSH=(ssh -o BatchMode=yes -o ConnectTimeout=10 "$PI_HOST")

say() { printf '\n== %s ==\n' "$*"; }
fail() { echo "DEPLOY-FAIL: $*" >&2; exit 1; }

if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
  echo "Hinweis: Arbeitskopie hat nicht committete Aenderungen; gebaut wird der Arbeitsbaum, nicht $SHA." >&2
fi

say "Ziel erreichbar?"
"${SSH[@]}" 'hostname; cat /proc/loadavg' || fail "Ziel nicht erreichbar ($PI_HOST). PI_HOST gesetzt? (siehe Kopf dieser Datei)"

say "Stand von vor 0.6?"
# Vor jeder Übertragung: Ein Abbruch hier lässt den Pi wirklich unverändert.
old_rc=0
git show HEAD:deploy/pi_switch.sh | "${SSH[@]}" 'bash -s -- oldname' || old_rc=$?
case "$old_rc" in
  0) ;;
  3) fail "Abbruch vor jeder Uebertragung (Grund oben); es wurde nichts veraendert." ;;
  *) fail "Pruefung auf einen Stand von vor 0.6 gescheitert (ssh/pi_switch.sh, Exit $old_rc); es wurde nichts veraendert." ;;
esac

if [ "$BUILD" = 1 ]; then
  say "Image fuer linux/arm64 auf dem PC bauen ($LOCAL_TAG)"
  # --pull: Basis-Images (python, node) jedes Mal frisch holen. Sonst bleibt die einmal geladene Fassung im Cache
  # liegen, und ihre Sicherheitslücken (z. B. in libcurl) landen bei jedem Deploy wieder im Image.
  # APT_REFRESH: Die Debian-Pakete (Tesseract samt Abhängigkeiten) einmal je Kalenderwoche frisch holen, sonst
  # bleiben sie mit dem Stand des ersten Baus im Cache. Nicht bei jedem Bau: Ein neuer Wert baut den ganzen
  # Laufzeit-Teil für arm64 unter QEMU neu.
  docker buildx build --pull --platform linux/arm64 -f deploy/Dockerfile \
    --build-arg APT_REFRESH="$(date +%G-W%V)" -t "$LOCAL_TAG" --load . \
    || fail "Build fehlgeschlagen."
else
  docker image inspect "$LOCAL_TAG" >/dev/null 2>&1 || fail "Kein lokales Image $LOCAL_TAG; ohne --no-build aufrufen."
fi
ARCH="$(docker image inspect "$LOCAL_TAG" --format '{{.Architecture}}')"
[ "$ARCH" = "arm64" ] || fail "Image ist $ARCH statt arm64."

say "deploy/-Ordner (compose, entrypoint) auf den Pi bringen"
git archive HEAD deploy | "${SSH[@]}" "mkdir -p ~/$DEPLOY_ROOT && tar -x -C ~/$DEPLOY_ROOT" \
  || fail "deploy/ konnte nicht uebertragen werden."

# Neuer Name, sonst alter Name (Umgebung und deploy/.env.deploy sind oben schon gemischt).
DNS_1="${NODVARD_DECK_DNS_1:-${LATTICE_DNS_1:-}}"
DNS_2="${NODVARD_DECK_DNS_2:-${LATTICE_DNS_2:-}}"
if [ -n "$DNS_1" ] || [ -n "$DNS_2" ]; then
  say "DNS-Einstellung fuer den Container auf dem Ziel ablegen (deploy/.env)"
  # Nur die neuen Namen: Auch der Rückweg auf das vorige Image startet mit dieser Compose-Datei, und die liest sie.
  # Ein nicht gesetzter Wert bleibt leer (keine Vorgabe, Compose lässt den Eintrag weg).
  printf 'NODVARD_DECK_DNS_1=%s\nNODVARD_DECK_DNS_2=%s\n' "$DNS_1" "$DNS_2" \
    | "${SSH[@]}" "cat > ~/$COMPOSE_DIR/.env" || fail "deploy/.env konnte nicht geschrieben werden."
fi

say "Image uebertragen (docker save | gzip | ssh | docker load)"
docker save "$LOCAL_TAG" | gzip -1 | "${SSH[@]}" 'gunzip | docker load' || fail "Image-Transfer fehlgeschlagen."

# --- Hilfen für den Schaltschritt -----------------------------------------------------------
# Aufruf eines Unterbefehls von deploy/pi_switch.sh auf dem Pi (Schritte dort, getestet).
pi_switch() { "${SSH[@]}" "cd ~/$COMPOSE_DIR && CONTAINER='$CONTAINER' bash pi_switch.sh $*"; }
# Umschalten und Zurückschalten laufen auf dem Pi von der ssh-Leitung abgekoppelt (setsid, Ausgabe
# in Dateien, Ergebnis danach hier ausgegeben): reißt die Verbindung ab, läuft der Schritt dort zu
# Ende, statt halb getan zu sterben. Exit 255 = Verbindung abgerissen, Zustand auf dem Pi unklar.
pi_switch_detached() { pi_switch detached "$@"; }

# Antwortet /api/v1/health mit "ok"? Wartet bis zu $1 Sekunden. Setzt HEALTHY=0/1 und RESCUE_SEEN=0/1.
#
# Die Notseite (`nodvard_deck.rescue`, läuft statt der Anwendung, wenn der Start scheitert) antwortet mit 503
# `{"status":"rescue"}` -- nie mit "ok", das Muster unten trifft sie also nie, und der Rückweg greift. Sie startet
# aber auch nie von selbst wieder: Meldet sie sich zweimal hintereinander, wird nicht die ganze Wartezeit abgewartet.
wait_health() {
  local limit="$1" i max BODY rescue_polls=0
  max=$((limit / 5)); [ "$max" -ge 1 ] || max=1
  HEALTHY=0
  RESCUE_SEEN=0
  for i in $(seq 1 "$max"); do
    BODY="$("${SSH[@]}" 'curl -s -m 3 localhost:8080/api/v1/health' 2>/dev/null || true)"
    if printf '%s' "$BODY" | grep -q '"status":"ok"'; then
      HEALTHY=1
      echo "healthy nach $((i * 5)) s: $BODY"
      return 0
    fi
    if printf '%s' "$BODY" | grep -q '"status":"rescue"'; then
      RESCUE_SEEN=1
      rescue_polls=$((rescue_polls + 1))
      if [ "$rescue_polls" -ge 2 ]; then
        echo "Notseite erkannt (nach $((i * 5)) s): Nodvard Deck ist nicht normal gestartet und startet auch nicht von selbst neu: $BODY"
        return 1
      fi
    else
      rescue_polls=0
    fi
    [ "$i" -lt "$max" ] && sleep 5
  done
  return 1
}

# Zurück auf den alten Stand, und PRUEFEN, dass er wirklich wieder antwortet. Endet immer
# mit fail: entweder "Rollback ausgefuehrt" oder ROLLBACK-FAIL.
rollback_and_fail() {
  echo "$1 -- Rollback auf den alten Stand." >&2
  "${SSH[@]}" "docker logs --since 5m $CONTAINER 2>&1 | tail -n 60" >&2 || true
  local rc=0
  pi_switch_detached rollback || rc=$?
  if [ "$rc" = 255 ]; then
    fail "Verbindung zum Pi waehrend des Rollbacks abgerissen, Zustand unklar. Bitte auf dem Pi pruefen: docker ps -a; cat ~/$COMPOSE_DIR/pi_switch.out; bash pi_switch.sh verify"
  fi
  if [ "$rc" = 0 ] && wait_health "$ROLLBACK_HEALTH_TIMEOUT_S"; then
    fail "Rollback ausgefuehrt, der alte Stand laeuft wieder und antwortet."
  fi
  echo "ROLLBACK-FAIL: Der alte Stand laeuft NICHT oder antwortet nicht. Auf dem Pi nachsehen: docker ps -a; bash pi_switch.sh rollback" >&2
  if [ "${RESCUE_SEEN:-0}" = 1 ]; then
    echo "Hinweis: Der alte Stand meldet den Notfallmodus (Notseite). Vermutlich hat das neue Image die Datenbank schon umgebaut, und sie ist fuer die alte Version zu neu." >&2
    echo "         Der Notfallcode steht im Protokoll (docker logs $CONTAINER). Auf der Notseite (Port 8080) laesst sich der Stand von vor dem Update wiederherstellen" >&2
    echo "         (Aenderungen seit dem Update gehen dabei verloren); das klappt ab einer Version, die diese Notseite schon kennt." >&2
  fi
  fail "ROLLBACK-FAIL: Dashboard vermutlich ausser Betrieb."
}

# Stand vor dem Umschalten (auf dem Pi gemessen, bis zu 5 Versuche): Hat der laufende Dienst schon
# Nutzer? Dann muss der neue dieselben Daten sehen. Auf einer frischen Maschine (kein Container
# läuft) ist "Einrichtung nötig" dagegen richtig. Läuft ein Dashboard, antwortet aber nicht,
# ist der Stand unklar: dann lieber abbrechen, solange noch nichts verändert ist.
say "Stand vor dem Umschalten pruefen"
PRE="$(pi_switch precheck)" || fail "Stand vor dem Umschalten nicht pruefbar (ssh/pi_switch.sh precheck gescheitert); es wurde nichts veraendert."
echo "Vorher: ${PRE:-<leer>}"
PRE_NEEDED=""
case "$PRE" in
  needed=false) PRE_NEEDED=false ;;
  http=*) PRE_NEEDED=false; echo "Hinweis: der laufende Dienst kennt /auth/bootstrap nicht ($PRE, altes Image); er laeuft mit dem Volume, die Daten gelten als vorhanden." ;;
  noanswer) fail "Nichts veraendert: Auf dem Pi laeuft ein Dashboard-Container, antwortet aber nicht. Erst klaeren (docker ps, docker logs), damit der Datencheck moeglich ist." ;;
esac

# Antwort von `pi_switch.sh loaded`: nur `none`, `invalid` oder `list=<Kennungen>` mit gültigen Kennungen (a-z, 0-9,
# Bindestrich, Anfang a-z, durch Komma getrennt; leer = keine). Alles andere gilt als `invalid`, damit nichts
# Unerwartetes aus der Verbindung je weiterverarbeitet oder ausgegeben wird.
clean_loaded_answer() {
  local re='^list=([a-z][a-z0-9-]*(,[a-z][a-z0-9-]*)*)?$'
  case "$1" in
    none|invalid) printf '%s' "$1" ;;
    *) if [[ "$1" =~ $re ]]; then printf '%s' "$1"; else printf 'invalid'; fi ;;
  esac
}

# Kennungen, die in $1 stehen, aber nicht in $2 (beide durch Komma getrennt, nur a-z, 0-9, Bindestrich), durch Komma
# getrennt auf stdout. Wird nur verglichen, nie als Befehl oder Muster ausgewertet; was nicht dem Muster entspricht, zählt
# als fehlend (lieber ein Fehlalarm als eine übersehene Erweiterung).
missing_ids() {
  local before="$1" now=",$2," id out="" ids
  local re='^[a-z0-9,-]*$'
  if [[ ! "$before" =~ $re ]] || [[ ! "$2" =~ $re ]]; then printf '%s' "$before"; return 0; fi
  IFS=, read -r -a ids <<<"$before"
  for id in "${ids[@]}"; do
    case "$now" in *",$id,"*) ;; *) out="${out:+$out,}$id" ;; esac
  done
  printf '%s' "$out"
}

# Migrationsstand nach dem Umschalten: Hat das neue Image die Datenbank umgebaut und ist gestartet (started_ok),
# lehnt das alte Image die Daten ab, und ein automatischer Rollback endete auf der Notseite. Ausgabe von boot_state:
# erste Zeile = Erstellzeit des Containers (UTC), danach .boot/state.json (vom Dienst mit indent=1 und sortierten
# Schlüsseln geschrieben: "at" ist die erste Zeile in "last_migration", Schlüssel der obersten Ebene haben genau
# ein Leerzeichen Einrückung). `docker cp` liest das Volume auch, wenn der Container nicht (mehr) läuft.
# Exit 255 = Verbindung abgerissen.
boot_state() {
  "${SSH[@]}" "docker container inspect -f '{{.Created}}' $CONTAINER 2>/dev/null; docker cp $CONTAINER:/app/data/.boot/state.json - 2>/dev/null | tar -xOf - 2>/dev/null; true" 2>/dev/null
}
mig_at() { sed -n '/"last_migration"/,/}/{/"at"/{s/.*"at": *"\([^"]*\)".*/\1/p;q;}}'; }

# Welche Erweiterungen hat der laufende Dienst geladen (letzte Zeile "Erweiterungen geladen: ..." in seinem Protokoll)?
# Nach dem Umschalten muss jede davon wieder geladen sein. Kennt der alte Stand die Zeile noch nicht (erster Lauf mit
# dieser Prüfung) oder ist sie aus dem Protokoll herausgerollt (Docker behält nur 3 x 10 MB), gibt es nichts zu
# vergleichen: dann nur ein Hinweis, kein Fehler. Auf einer frischen Maschine (nichts läuft) entfällt es ganz.
PRE_LOADED=""   # leer = kein Vergleich
if [ "$PRE" != none ]; then
  PRE_LOADED_RC=0
  PRE_LOADED_ANSWER="$(pi_switch loaded)" || PRE_LOADED_RC=$?
  if [ "$PRE_LOADED_RC" != 0 ]; then
    fail "Die geladenen Erweiterungen des laufenden Dienstes liessen sich nicht lesen (ssh/pi_switch.sh loaded gescheitert, Code $PRE_LOADED_RC); es wurde nichts veraendert."
  fi
  PRE_LOADED_ANSWER="$(clean_loaded_answer "$PRE_LOADED_ANSWER")"
  case "$PRE_LOADED_ANSWER" in
    list=?*) PRE_LOADED="${PRE_LOADED_ANSWER#list=}"; echo "Vorher geladene Erweiterungen: $PRE_LOADED" ;;
    list=)   echo "Vorher war keine Erweiterung geladen: nichts zu vergleichen." ;;
    invalid) echo "Hinweis: Die Zeile \"Erweiterungen geladen\" im Protokoll des laufenden Dienstes ist nicht lesbar. Ob nachher alle Erweiterungen wieder laufen, wird nicht verglichen." ;;
    *)       echo "Hinweis: Das Protokoll des laufenden Dienstes hat keine Zeile \"Erweiterungen geladen\" (aelterer Stand, oder sie ist herausgerollt). Ob nachher alle Erweiterungen wieder laufen, wird nicht verglichen." ;;
  esac
fi

say "Auf dem Pi umschalten"
# Exit 3 von pi_switch.sh = nichts verändert (Docker nicht erreichbar, Image fehlt,
# Compose-Datei nicht lesbar): kein Rollback. Sonst ist der Dienst ggf. schon unten.
SWITCH_RC=0
pi_switch_detached switch "'$LOCAL_TAG'" || SWITCH_RC=$?
if [ "$SWITCH_RC" = 3 ]; then
  fail "Umschalten nicht begonnen, es wurde nichts veraendert (Meldung oben)."
elif [ "$SWITCH_RC" = 255 ]; then
  fail "Verbindung zum Pi waehrend des Umschaltens abgerissen. Der Schritt laeuft dort moeglicherweise zu Ende. KEIN automatischer Rollback. Bitte auf dem Pi pruefen: docker ps -a; cat ~/$COMPOSE_DIR/pi_switch.out; bash pi_switch.sh verify"
elif [ "$SWITCH_RC" != 0 ]; then
  rollback_and_fail "Umschalten ist fehlgeschlagen"
fi

say "Auf Health warten (bis $HEALTH_TIMEOUT_S s)"
HEALTH_WAIT_STARTED=$SECONDS
wait_health "$HEALTH_TIMEOUT_S" || true
if [ "${RESCUE_SEEN:-0}" = 1 ] && [ "$HEALTHY" != 1 ]; then
  echo "Die Notseite von Nodvard Deck antwortet statt der Anwendung: Der Start des neuen Images ist gescheitert (z. B. die Migration)." >&2
  echo "Ein Grund steht im Protokoll auf dem Pi: docker logs $CONTAINER." >&2
fi

# "gesund" allein reicht nicht (könnte ein alter Container sein, oder ein leeres Volume):
# Der Container muss das neue Image tragen, laufen und das alte Volume haben ...
# CRASHED=1 (Exit 4): richtiges Volume, aber nach dem Health-Ok nicht mehr laufend (Absturz, Neustart-Schleife).
IMAGE_OK=0
CRASHED=0
if [ "$HEALTHY" = 1 ]; then
  VERIFY_RC=0
  pi_switch verify || VERIFY_RC=$?
  if [ "$VERIFY_RC" = 0 ]; then
    IMAGE_OK=1
  elif [ "$VERIFY_RC" = 4 ]; then
    CRASHED=1
  elif [ "$VERIFY_RC" = 255 ]; then
    fail "Verbindung zum Pi bei der Image/Volume-Pruefung abgerissen, der Zustand ist unklar. KEIN automatischer Rollback. Bitte auf dem Pi pruefen: bash pi_switch.sh verify; docker ps -a"
  fi
fi

# Hat dieser Container die Datenbank umgebaut (Migration nicht älter als der Container) und ist gestartet?
# BOOT_UNKNOWN=1: nicht feststellbar (Verbindung abgerissen oder state.json nicht lesbar).
MIGRATED=0
MIG_AFTER=""
BOOT_UNKNOWN=0
if [ "$HEALTHY" = 1 ]; then
  BOOT_RC=0
  BOOT_AFTER="$(boot_state)" || BOOT_RC=$?
  if [ "$BOOT_RC" = 255 ]; then
    sleep 2
    BOOT_RC=0
    BOOT_AFTER="$(boot_state)" || BOOT_RC=$?
  fi
  CREATED="$(head -n 1 <<<"$BOOT_AFTER")"
  MIG_AFTER="$(mig_at <<<"$BOOT_AFTER")"
  TOP_STARTED=$'\n "started_ok": '
  if [ "$BOOT_RC" = 255 ] || [ -z "$CREATED" ] || [[ "$BOOT_AFTER" != *"$TOP_STARTED"* ]]; then
    BOOT_UNKNOWN=1
  elif [ -n "$MIG_AFTER" ] && [[ ! "${MIG_AFTER:0:19}" < "${CREATED:0:19}" ]] && [[ "$BOOT_AFTER" == *"${TOP_STARTED}true"* ]]; then
    MIGRATED=1
  fi
fi

# ... und die Daten müssen da sein: war der Dienst vorher eingerichtet, darf er es jetzt
# nicht als "Einrichtung nötig" melden.
DATA_OK=1
if [ "$HEALTHY" = 1 ] && [ "$PRE_NEEDED" = false ]; then
  BOOTSTRAP_RC=0
  POST="$(pi_switch bootstrap 3 2>/dev/null)" || BOOTSTRAP_RC=$?
  if [ "$BOOTSTRAP_RC" = 255 ]; then
    fail "Verbindung zum Pi beim Datencheck abgerissen, der Zustand ist unklar. KEIN automatischer Rollback. Bitte auf dem Pi pruefen: bash pi_switch.sh bootstrap; docker logs --since 5m $CONTAINER"
  fi
  if [ "$POST" != "needed=false" ]; then
    DATA_OK=0
    echo "Datencheck: /api/v1/auth/bootstrap meldet ${POST:-<keine Antwort>}, vorher war der Dienst eingerichtet -- die Daten fehlen?" >&2
  else
    echo "Datencheck ok: needed=false wie vorher."
  fi
fi

# Erweiterungen: Jede, die vorher geladen war, muss auch im neuen Container geladen sein (neue dazu sind in Ordnung).
# Die Zeile steht schon vor dem ersten Health-Ok im Protokoll; gewartet wird nur auf die Verzögerung des Docker-Protokolls:
# höchstens LOADED_WAIT_S, nie über den Rest der Health-Wartezeit hinaus (mindestens 5 s). Fehlt die Zeile ganz, obwohl der alte
# Stand eine hatte, gilt das wie eine fehlende Erweiterung: Der Vergleich ist dann nicht möglich, und genau so sieht ein
# Stand aus, der seine Erweiterungen nicht mehr meldet.
LOADED_OK=1
LOADED_MISSING=""
if [ "$HEALTHY" = 1 ] && [ "$IMAGE_OK" = 1 ] && [ "$DATA_OK" = 1 ] && [ -n "$PRE_LOADED" ]; then
  LOADED_LEFT=$((HEALTH_TIMEOUT_S - (SECONDS - HEALTH_WAIT_STARTED)))
  [ "$LOADED_LEFT" -ge 5 ] || LOADED_LEFT=5
  LOADED_SECS="$LOADED_WAIT_S"
  [ "$LOADED_SECS" -le "$LOADED_LEFT" ] || LOADED_SECS="$LOADED_LEFT"
  POST_LOADED_RC=0
  POST_LOADED="$(pi_switch loaded "$LOADED_SECS")" || POST_LOADED_RC=$?
  if [ "$POST_LOADED_RC" != 0 ]; then
    fail "Die geladenen Erweiterungen des neuen Stands liessen sich nicht lesen (Verbindung abgerissen? Code $POST_LOADED_RC), der Zustand ist unklar. KEIN automatischer Rollback. Bitte auf dem Pi pruefen: bash pi_switch.sh loaded; docker logs $CONTAINER 2>&1 | grep 'Erweiterungen geladen'; bash pi_switch.sh verify"
  fi
  POST_LOADED="$(clean_loaded_answer "$POST_LOADED")"
  case "$POST_LOADED" in
    list=*)
      LOADED_MISSING="$(missing_ids "$PRE_LOADED" "${POST_LOADED#list=}")"
      if [ -n "$LOADED_MISSING" ]; then
        LOADED_OK=0
        echo "Erweiterungen: Vorher geladen, jetzt nicht mehr: $LOADED_MISSING (vorher: $PRE_LOADED; jetzt: ${POST_LOADED#list=})." >&2
        echo "  Soll eine davon mit dem neuen Stand bewusst wegfallen: sie erst in den Einstellungen ausschalten, dann das Deploy wiederholen." >&2
      else
        echo "Erweiterungen ok: alles, was vorher geladen war, ist wieder geladen ($PRE_LOADED)."
      fi
      ;;
    *)
      LOADED_OK=0
      LOADED_MISSING="$PRE_LOADED"
      echo "Erweiterungen: Der neue Stand meldet keine lesbare Zeile \"Erweiterungen geladen\" (Antwort: ${POST_LOADED:-<keine>}), der alte meldete: $PRE_LOADED. Ein Vergleich ist nicht moeglich." >&2
      ;;
  esac
fi

TRACEBACKS=0
if [ "$HEALTHY" = 1 ] && [ "$IMAGE_OK" = 1 ] && [ "$DATA_OK" = 1 ]; then
  TRACEBACKS="$("${SSH[@]}" "docker logs --since 5m $CONTAINER 2>&1 | grep -c Traceback || true")" || TRACEBACKS=unbekannt
  case "$TRACEBACKS" in
    ''|*[!0-9]*)
      fail "Die Log-Pruefung auf dem Pi ist gescheitert (Verbindung abgerissen?), der Zustand ist unklar. KEIN automatischer Rollback. Bitte auf dem Pi pruefen: docker logs --since 5m $CONTAINER | grep -c Traceback; bash pi_switch.sh verify" ;;
  esac
fi

if [ "$HEALTHY" != 1 ] || [ "$IMAGE_OK" != 1 ] || [ "$DATA_OK" != 1 ] || [ "$LOADED_OK" != 1 ] || [ "${TRACEBACKS:-0}" -gt 0 ]; then
  # Nur wenn der neue Container mit richtigem Image und Volume lief (IMAGE_OK) oder danach abgestürzt ist (CRASHED):
  # bei falschem Image oder Volume sind die alten Daten unberührt, der Rollback ist sicher.
  if [ "$HEALTHY" = 1 ] && { [ "$IMAGE_OK" = 1 ] || [ "$CRASHED" = 1 ]; }; then
    PROBLEM="Datencheck=$DATA_OK, Tracebacks=${TRACEBACKS:-0}, Erweiterungen=$LOADED_OK"
    [ "$LOADED_OK" = 1 ] || PROBLEM="$PROBLEM (fehlen: $LOADED_MISSING)"
    [ "$CRASHED" = 1 ] && PROBLEM="laeuft nach dem Health-Ok nicht mehr: abgestuerzt oder Neustart-Schleife"
    BACK="Zurueck nur bewusst: bash pi_switch.sh rollback, danach auf der Notseite (Port 8080) den Stand vor dem Update wiederherstellen; der Notfallcode steht im Protokoll (docker logs $CONTAINER). Aenderungen seit dem Update gehen dabei verloren. Das klappt nur, wenn die alte Version (nodvard-deck:previous) die Notseite schon kennt; sonst die neue Version laufen lassen und den Fehler dort beheben."
    if [ "$BOOT_UNKNOWN" = 1 ]; then
      fail "Neuer Stand meldet Probleme ($PROBLEM), ob er die Datenbank schon umgebaut hat, liess sich aber nicht feststellen (.boot/state.json nicht lesbar oder Verbindung abgerissen). Zustand unklar, KEIN automatischer Rollback. Bitte auf dem Pi pruefen: docker ps -a; docker cp $CONTAINER:/app/data/.boot/state.json - | tar -xOf -; docker logs --since 10m $CONTAINER. $BACK"
    fi
    if [ "$MIGRATED" = 1 ]; then
      fail "Neuer Stand meldet Probleme ($PROBLEM), hat die Datenbank aber schon umgebaut und war gestartet (Migration $MIG_AFTER). KEIN automatischer Rollback: das alte Image wuerde die Daten ablehnen und nur die Notseite zeigen. Bitte pruefen: docker ps -a; docker logs --since 10m $CONTAINER. $BACK"
    fi
  fi
  rollback_and_fail "Neuer Stand ist nicht in Ordnung (healthy=$HEALTHY, Image/Volume-Pruefung=$IMAGE_OK, Datencheck=$DATA_OK, Tracebacks=$TRACEBACKS, Erweiterungen=$LOADED_OK${LOADED_MISSING:+, fehlen: $LOADED_MISSING})"
fi

say "Aufraeumen (alte Images)"
# Erst jetzt, wo alles geprüft ist. Ein Fehler hier lässt den Deploy nicht scheitern.
pi_switch cleanup || echo "Hinweis: Aufraeumen auf dem Pi war nicht vollstaendig (pi_switch.sh cleanup)." >&2
"${SSH[@]}" "docker image prune -f >/dev/null; docker images nodvard-deck --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}'; cat /proc/loadavg; free -m | sed -n 2p"

echo
echo "DEPLOY-OK: $SHA laeuft auf $PI_HOST (Rollback: nodvard-deck:previous)."