Files
Florian Wilde 39098cd201
CI / pruefen (pull_request) Canceled after 0s
chore: Update-Skript erkennt Container-Zustand, Merkposten ergaenzt
Das Update-Skript prüfte allein http://127.0.0.1:APP_PORT/api/health. Hinter
einem Reverse Proxy ist der App-Port oft gar nicht auf dem Host
veröffentlicht — dann meldete das Skript nach zwei Minuten einen Fehlschlag,
obwohl das Update sauber durchgelaufen war.

Schlägt die Abfrage vom Host fehl, zählt jetzt der HEALTHCHECK des Containers.
Den bringt das Image schon mit; er fragt /api/health von innen ab und ist
damit unabhängig von der Portveröffentlichung.

Im Fehlerfall nennt das Skript zusätzlich den Container-Zustand und ordnet ihn
ein: kein Container (Start fehlgeschlagen), startet noch (Migrationen dauern),
läuft aber ungesund (Fehler in der Anwendung), oder ein älteres Image ohne
Healthcheck. Ohne diese Auskunft sah ein nicht veröffentlichter Port genauso
aus wie eine abgestürzte Anwendung.

container_zustand() gibt bewusst immer 0 zurück — unter set -e dürfte die
Diagnose nicht selbst abbrechen, bevor der Weg zurück ausgegeben ist.

Dazu drei Merkposten in CLAUDE.md, die diese Sitzung Zeit gekostet haben:
Klickwege müssen gegen einen Produktionsbau geprüft werden (im
Entwicklungsmodus hydratisiert unter headless Chromium nichts, weil der
HMR-Socket scheitert — ohne jede Fehlermeldung), vor npm start den Port
prüfen, und dass scripts/shot.mjs sich nicht anmeldet.
2026-09-02 00:11:22 +02:00

308 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Flimmer aktualisieren.
#
# ./update.sh sichern, holen, bauen, neu starten
# ./update.sh --ohne-backup ohne Datenbanksicherung
# ./update.sh --ohne-pull nur neu bauen und starten (kein git pull)
# ./update.sh --hilfe
#
# Grundhaltung: Erst sichern, dann anfassen. Bricht etwas ab, steht am Ende,
# wie der Weg zurück aussieht — mit dem konkreten Befehl und der konkreten
# Sicherungsdatei, nicht bloß dem Rat, ein Backup zu haben.
set -euo pipefail
# --- Ausgabe -----------------------------------------------------------------
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
AKZENT=$'\033[38;5;214m'; GUT=$'\033[32m'; WARN=$'\033[33m'
FEHLER=$'\033[31m'; LEISE=$'\033[2m'; AUS=$'\033[0m'
else
AKZENT=""; GUT=""; WARN=""; FEHLER=""; LEISE=""; AUS=""
fi
schritt() { printf '\n%s▸ %s%s\n' "$AKZENT" "$1" "$AUS"; }
info() { printf ' %s\n' "$1"; }
leise() { printf ' %s%s%s\n' "$LEISE" "$1" "$AUS"; }
gut() { printf ' %s✓ %s%s\n' "$GUT" "$1" "$AUS"; }
warnung() { printf ' %s! %s%s\n' "$WARN" "$1" "$AUS"; }
abbruch() { printf '\n%s✗ %s%s\n' "$FEHLER" "$1" "$AUS" >&2; exit 1; }
hilfe() {
cat <<'ENDE'
Flimmer aktualisieren
./update.sh [OPTIONEN]
Ablauf: Datenbank sichern → Änderungen holen → Image bauen → neu starten →
warten, bis die Instanz wieder antwortet. Die Datenbankmigrationen spielt der
Container beim Start selbst ein.
Geprüft wird zuerst http://127.0.0.1:APP_PORT/api/health. Ist der Port gar
nicht auf dem Host veröffentlicht — üblich hinter einem Reverse Proxy —,
zählt stattdessen der Healthcheck des Containers.
Optionen:
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
--ohne-pull Nichts aus dem Repository holen, nur neu bauen und starten
--behalte N Wie viele Sicherungen aufgehoben werden (Standard: 10)
--hilfe, -h Diese Übersicht
Die Sicherungen liegen unter backups/ und sind gzip-komprimierte pg_dumps.
ENDE
}
# --- Optionen ----------------------------------------------------------------
MIT_BACKUP=1
MIT_PULL=1
BEHALTE=10
while [ $# -gt 0 ]; do
case "$1" in
--ohne-backup) MIT_BACKUP=0 ;;
--ohne-pull) MIT_PULL=0 ;;
--behalte) shift; BEHALTE="${1:-10}" ;;
--hilfe|-h) hilfe; exit 0 ;;
*) abbruch "Unbekannte Option: $1 (./update.sh --hilfe)" ;;
esac
shift
done
# --- Voraussetzungen ---------------------------------------------------------
cd "$(dirname "$0")"
[ -f docker-compose.yml ] || abbruch "Hier liegt keine docker-compose.yml. Das Skript gehört ins Flimmer-Verzeichnis."
[ -f .env ] || abbruch "Es fehlt die .env. Lege sie an: cp .env.example .env"
command -v docker >/dev/null 2>&1 || abbruch "docker ist nicht installiert oder nicht im Pfad."
if docker compose version >/dev/null 2>&1; then
COMPOSE="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE="docker-compose"
else
abbruch "Weder 'docker compose' noch 'docker-compose' gefunden."
fi
docker info >/dev/null 2>&1 || abbruch "Der Docker-Dienst antwortet nicht. Läuft er?"
# Werte aus der .env lesen, ohne sie auszuführen.
env_wert() {
local schluessel="$1" standard="${2:-}"
local zeile
zeile=$(grep -E "^[[:space:]]*${schluessel}=" .env 2>/dev/null | tail -1 || true)
if [ -z "$zeile" ]; then printf '%s' "$standard"; return; fi
zeile="${zeile#*=}"
zeile="${zeile%\"}"; zeile="${zeile#\"}"
zeile="${zeile%\'}"; zeile="${zeile#\'}"
printf '%s' "$zeile"
}
DB_USER=$(env_wert POSTGRES_USER flimmer)
DB_NAME=$(env_wert POSTGRES_DB flimmer)
PORT=$(env_wert APP_PORT 3000)
printf '%s\n' "Flimmer aktualisieren"
leise "Verzeichnis: $(pwd)"
leise "Compose: $COMPOSE"
# --- Stand festhalten --------------------------------------------------------
IST_GIT=0
VORHER_REF=""
if [ -d .git ] && command -v git >/dev/null 2>&1; then
IST_GIT=1
VORHER_REF=$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")
fi
VORHER_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
leise "Aktuell: Version $VORHER_VERSION${VORHER_REF:+ (${VORHER_REF})}"
# --- Sicherung ---------------------------------------------------------------
SICHERUNG=""
if [ "$MIT_BACKUP" -eq 1 ]; then
schritt "Datenbank sichern"
# -q gibt es in beiden Compose-Fassungen; --status erst ab v2.
if [ -z "$($COMPOSE ps -q db 2>/dev/null)" ]; then
warnung "Die Datenbank läuft nicht — es gibt also auch nichts zu sichern."
else
mkdir -p backups
SICHERUNG="backups/flimmer-$(date +%Y-%m-%d-%H%M%S).sql.gz"
if $COMPOSE exec -T db pg_dump -U "$DB_USER" -d "$DB_NAME" --clean --if-exists \
| gzip > "$SICHERUNG" 2>/dev/null && [ -s "$SICHERUNG" ]; then
gut "$SICHERUNG ($(du -h "$SICHERUNG" | cut -f1))"
else
rm -f "$SICHERUNG"
SICHERUNG=""
abbruch "Die Sicherung ist fehlgeschlagen. Abbruch — ohne Sicherung wird hier nichts aktualisiert.
Wenn du es trotzdem willst: ./update.sh --ohne-backup"
fi
# Ältere Sicherungen aufräumen, die jüngsten bleiben.
if [ "$BEHALTE" -gt 0 ]; then
ANZAHL=$(ls -1 backups/flimmer-*.sql.gz 2>/dev/null | wc -l)
if [ "$ANZAHL" -gt "$BEHALTE" ]; then
ls -1t backups/flimmer-*.sql.gz | tail -n +$((BEHALTE + 1)) | xargs -r rm --
leise "Ältere Sicherungen entfernt, die letzten $BEHALTE bleiben."
fi
fi
fi
else
warnung "Ohne Sicherung — auf eigenes Risiko."
fi
# --- Änderungen holen --------------------------------------------------------
if [ "$MIT_PULL" -eq 1 ]; then
schritt "Änderungen holen"
if [ "$IST_GIT" -eq 0 ]; then
warnung "Kein Git-Repository — übersprungen."
elif [ -n "$(git status --porcelain 2>/dev/null)" ]; then
warnung "Es gibt lokale Änderungen; git pull wird übersprungen."
leise "Ansehen mit: git status"
else
ZWEIG=$(git rev-parse --abbrev-ref HEAD)
if git pull --ff-only 2>&1 | sed 's/^/ /'; then
NACHHER_REF=$(git rev-parse --short HEAD)
if [ "$NACHHER_REF" = "$VORHER_REF" ]; then
gut "Schon auf dem neuesten Stand ($ZWEIG)."
else
gut "$VORHER_REF → $NACHHER_REF"
fi
else
abbruch "git pull ist fehlgeschlagen. Vermutlich sind Zweig und Gegenstelle auseinandergelaufen."
fi
fi
fi
NEUE_VERSION=$(grep -m1 '"version"' package.json 2>/dev/null | sed 's/.*"version"[^"]*"\([^"]*\)".*/\1/' || echo "?")
# --- Bauen und starten -------------------------------------------------------
schritt "Image bauen"
info "Das dauert beim ersten Mal einige Minuten."
$COMPOSE build 2>&1 | sed 's/^/ /' || abbruch "Der Build ist fehlgeschlagen. Die laufende Instanz ist unangetastet."
gut "Gebaut."
schritt "Neu starten"
$COMPOSE up -d 2>&1 | sed 's/^/ /' || abbruch "Der Start ist fehlgeschlagen. Log ansehen: $COMPOSE logs -f app"
gut "Container laufen."
# --- Warten, bis die Instanz antwortet ---------------------------------------
schritt "Auf die Instanz warten"
info "Die Datenbankmigrationen spielt der Container beim Start selbst ein."
# Der Container meldet seinen Zustand selbst — der HEALTHCHECK im Image fragt
# /api/health von innen ab. Das ist der verlässlichere Weg: hinter einem
# Reverse Proxy ist der App-Port oft gar nicht auf dem Host veröffentlicht,
# und dann sagt ein fehlschlagendes curl nichts über die Instanz aus.
# Gibt immer 0 zurück: die Funktion läuft auch im Fehlerpfad, und unter
# `set -e` dürfte eine Auskunft über den Zustand nicht selbst das Skript
# abbrechen, bevor der Weg zurück ausgegeben wurde.
container_zustand() {
local id
id=$($COMPOSE ps -q app 2>/dev/null || true)
if [ -z "$id" ]; then
printf 'kein-container'
return 0
fi
# Ohne HEALTHCHECK im Image ist .State.Health leer — dann bleibt der
# Laufzustand (running/exited) als grobe Auskunft.
docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}ohne-healthcheck:{{.State.Status}}{{end}}' \
"$id" 2>/dev/null || printf 'unbekannt'
return 0
}
GESUND=0
UEBER_CONTAINER=0
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then
GESUND=1
break
fi
if [ "$(container_zustand)" = "healthy" ]; then
GESUND=1
UEBER_CONTAINER=1
break
fi
sleep 2
done
if [ "$GESUND" -eq 1 ]; then
if [ "$UEBER_CONTAINER" -eq 1 ]; then
gut "Der Container meldet sich gesund."
leise "Auf Port ${PORT} war nichts zu erreichen — das ist normal, wenn die App"
leise "nur über einen Reverse Proxy erreichbar ist und den Port nicht veröffentlicht."
else
ANTWORT=$(curl -fsS "http://127.0.0.1:${PORT}/api/health" 2>/dev/null || true)
gut "Flimmer antwortet auf Port ${PORT}."
leise "$ANTWORT"
fi
printf '\n%s✓ Fertig — Version %s%s\n' "$GUT" "$NEUE_VERSION" "$AUS"
if [ "$VORHER_VERSION" != "$NEUE_VERSION" ]; then
info "Was sich geändert hat, steht in CHANGELOG.md."
fi
if [ -n "$SICHERUNG" ]; then
leise "Sicherung: $SICHERUNG"
fi
exit 0
fi
# --- Fehlschlag: konkreter Weg zurück ----------------------------------------
printf '\n%s✗ Die Instanz antwortet nach zwei Minuten nicht.%s\n\n' "$FEHLER" "$AUS" >&2
# Der Container-Zustand grenzt ein, wo es klemmt — ohne ihn sähe ein nicht
# veröffentlichter Port genauso aus wie eine abgestürzte App.
ZUSTAND=$(container_zustand)
case "$ZUSTAND" in
kein-container)
info "Es läuft gar kein app-Container. Der Start ist offenbar fehlgeschlagen."
;;
starting)
info "Der Container startet noch. Möglicherweise dauern die Migrationen länger —"
info "sieh im Log nach und gib ihm etwas Zeit."
;;
unhealthy)
info "Der Container läuft, meldet sich aber als nicht gesund. Der Fehler liegt"
info "also in der Anwendung, nicht am Port."
;;
ohne-healthcheck:running)
info "Der Container läuft, das Image bringt aber keinen Healthcheck mit (ältere"
info "Fassung). Ob die App wirklich antwortet, verrät nur das Log."
;;
ohne-healthcheck:*)
info "Der Container ist nicht gelaufen: ${ZUSTAND#ohne-healthcheck:}"
;;
*)
info "Container-Zustand: ${ZUSTAND}"
;;
esac
printf '\n'
info "Log ansehen:"
printf ' %s logs --tail 50 app\n\n' "$COMPOSE"
if [ -n "$SICHERUNG" ] || [ -n "$VORHER_REF" ]; then
info "Weg zurück:"
if [ "$IST_GIT" -eq 1 ] && [ -n "$VORHER_REF" ]; then
printf ' git checkout %s && %s up -d --build\n' "$VORHER_REF" "$COMPOSE"
fi
if [ -n "$SICHERUNG" ]; then
printf ' gunzip -c %s | %s exec -T db psql -U %s -d %s\n' \
"$SICHERUNG" "$COMPOSE" "$DB_USER" "$DB_NAME"
fi
printf '\n'
fi
exit 1