open_notebook/scripts/update_stack.sh
dschlueter 5b389fae58 Guard against stopping the stack while a podcast is running
Stopping the stack kills an in-flight podcast job, but its status stays "running"
in the database forever: it never finishes, it blocks prune_podcast_data.py (which
deliberately refuses to run while a job is active), and it cannot even be retried —
/retry only accepts episodes in state "failed".

I walked into this myself: I ran `docker compose down` for the non-root switch
without checking for running jobs, and killed a podcast that had already produced
37 clips.

update_stack.sh now refuses to start when a podcast job is running, and says why.
Verified against a genuinely running job: it aborts before touching the stack.

BEDIENUNGSANLEITUNG documents how to recover an existing zombie. The status does not
live on the episode but on the linked `command` record (the episode's own job_status
is null), so the fix is to set that record to 'failed' via SurrealDB, after which the
normal retry works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 15:29:14 +02:00

120 lines
4.6 KiB
Bash
Executable file

#!/usr/bin/env bash
# Aktualisiert Open Notebook auf das neueste veroeffentlichte Image — mit Rueckfallpunkt.
#
# Ablauf:
# 1. Neuen Digest aus der Registry holen (nichts zu tun, wenn schon aktuell).
# 2. Stack stoppen und Daten sichern (surreal_data + notebook_data + docker-compose.yml).
# 3. Digest in docker-compose.yml eintragen, Stack starten.
# 4. Rauchtest (scripts/smoke_test.sh).
# 5. Rot -> alles zurueckrollen (Daten UND Compose-Datei). Gruen -> fertig.
#
# Der Rollback der Daten ist der Grund fuer das Backup: Open Notebook migriert die
# SurrealDB beim Start, und ein Downgrade der Anwendung kann eine migrierte Datenbank
# nicht wieder lesen. Ohne Backup waere ein missgluecktes Update eine Einbahnstrasse.
#
# ./scripts/update_stack.sh # auf neuestes Image
# ./scripts/update_stack.sh --dry-run # nur zeigen, was passieren wuerde
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
TAG="lfnovo/open_notebook:v1-latest"
BACKUP_DIR="$REPO/backups"
KEEP_BACKUPS=3
DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
say() { printf '\n\033[1m%s\033[0m\n' "$1"; }
# Gezielt aus der open_notebook-Zeile. Ein blindes grep auf den ersten Digest der Datei
# erwischt surrealdb (steht weiter oben) — das sed weiter unten wuerde dann den falschen
# Digest ersetzen und die Datenbank austauschen statt der Anwendung.
pinned="$(grep -oP 'lfnovo/open_notebook\S*@\Ksha256:[0-9a-f]+' docker-compose.yml | head -1)"
[ -n "$pinned" ] || { echo "FEHLER: kein open_notebook-Digest in docker-compose.yml gepinnt." >&2; exit 1; }
say "1/5 Registry abfragen"
remote="$(docker buildx imagetools inspect "$TAG" --format '{{.Manifest.Digest}}')"
echo " gepinnt: $pinned"
echo " neu : $remote"
if [ "$remote" = "$pinned" ]; then
echo " Bereits aktuell — nichts zu tun."
exit 0
fi
if [ "$DRY_RUN" = 1 ]; then
echo " (--dry-run: hier wuerde das Update starten)"
exit 0
fi
# Ein laufender Podcast ueberlebt das Stoppen nicht: der Job ist weg, sein Status bleibt
# aber auf "running" stehen. Er wird nie fertig, blockiert prune_podcast_data.py und laesst
# sich nicht per /retry wiederholen (das will "failed"). Also vorher fragen, nicht nachher
# aufraeumen.
running_jobs="$(curl -sf -m 10 http://127.0.0.1:5055/api/podcasts/episodes 2>/dev/null |
python3 -c "
import json,sys
try: eps = json.load(sys.stdin)
except Exception: sys.exit(0)
print('; '.join(e['name'] for e in eps if e.get('job_status') in ('running','pending','submitted')))" 2>/dev/null)"
if [ -n "${running_jobs// /}" ]; then
echo
echo "ABBRUCH: Es laeuft gerade ein Podcast-Job: $running_jobs"
echo " Das Stoppen wuerde ihn abschiessen und als Karteileiche zuruecklassen."
echo " Bitte warten, bis er fertig ist, und danach erneut starten."
exit 1
fi
ts="$(date +%Y%m%d-%H%M%S)"
backup="$BACKUP_DIR/$ts"
mkdir -p "$backup"
say "2/5 Stack stoppen und sichern -> backups/$ts"
docker compose down
cp docker-compose.yml "$backup/docker-compose.yml"
# Direkt auf dem Host, weil die Container als Host-User laufen (user: "1000:1000")
# und die Daten uns gehoeren. Liefen sie als root, scheiterte das an den Rechten.
tar -czf "$backup/data.tgz" surreal_data notebook_data
echo " $(du -sh "$backup/data.tgz" | cut -f1) gesichert"
restore() {
say "ROLLBACK"
docker compose down || true
cp "$backup/docker-compose.yml" docker-compose.yml
rm -rf surreal_data notebook_data
tar -xzf "$backup/data.tgz"
docker compose up -d
echo " Alter Stand ($pinned) ist wiederhergestellt, Daten aus backups/$ts."
echo " Das neue Image bleibt lokal liegen — Ursache pruefen, dann erneut versuchen."
}
say "3/5 Auf neues Image umstellen"
sed -i "s|@$pinned|@$remote|" docker-compose.yml
docker compose up -d
echo " gestartet, warte auf die API..."
for _ in $(seq 1 30); do
curl -sf -m 3 http://127.0.0.1:5055/api/models/providers >/dev/null 2>&1 && break
sleep 5
done
say "4/5 Rauchtest"
if ./scripts/smoke_test.sh; then
say "5/5 Fertig"
newver="$(docker compose exec -T open_notebook grep -m1 '^version' /app/pyproject.toml |
grep -oP '(?<=")[^"]+' || echo '?')"
echo " Update auf $newver erfolgreich, Rauchtest gruen."
echo " Backup: backups/$ts (bleibt liegen)"
echo
echo " Bitte den neuen Digest committen:"
echo " git add docker-compose.yml && git commit -m 'Update Open Notebook auf $newver'"
else
echo
echo " Rauchtest ROT — das Update wird zurueckgenommen."
restore
exit 1
fi
# Alte Backups aufraeumen (Platte ist knapp).
ls -1dt "$BACKUP_DIR"/*/ 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | while read -r old; do
echo " raeume altes Backup weg: $(basename "$old")"
rm -rf "$old"
done