feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte

Dreistufiges Dockerfile mit standalone-Ausgabe; im Laufzeitabbild liegen
weder Quelltext noch Bauwerkzeuge, und es läuft unter einem eigenen
Benutzer.

Die Migrationen erledigt ein eigener Einmaldienst aus der Bau-Stufe. Der
Versuch, nur die Prisma-CLI ins Laufzeitabbild zu kopieren, scheiterte an
ihren transitiven Modulen; sie alle mitzunehmen hätte das Abbild
aufgebläht. Der Dienst läuft vor der App und muss sauber durchlaufen.

Zwei Stellen mussten faul werden, weil Next beim Bauen Seitendaten
sammelt und dabei Module lädt, die es zur Bauzeit noch ohne
Umgebungsvariablen gibt:
- Der Prisma-Client entsteht erst beim ersten Zugriff, über einen Proxy.
- Die Auth.js-Konfiguration wird als Funktion übergeben.
Ebenso liest prisma.config.ts die URL über process.env statt über env(),
das beim Bauen ohne Datenbank abbricht.

Dazu backup.sh, update.sh mit Rückrollpfad und scripts/release.sh. Der
Dump wurde nachweislich in eine Wegwerf-Datenbank zurückgespielt und
stimmt zeilengenau mit dem Original überein. Alle Skripte sind
shellcheck-sauber.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-10 14:08:04 +02:00
co-authored by Claude Opus 5
parent 126ad93301
commit f91d015cc4
9 changed files with 523 additions and 28 deletions
Executable
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env bash
# Aktualisiert den Planer auf eine neue Version.
# Für Linux mit Docker Compose. Läuft im Projektverzeichnis.
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$WURZEL"
ZIELVERSION=""
OHNE_RUECKFRAGE=0
OHNE_BACKUP=0
NUR_PRUEFEN=0
WARTEZEIT=180
LOGVERZEICHNIS="$WURZEL/logs"
LOGDATEI="$LOGVERZEICHNIS/update-$(date '+%Y-%m-%d_%H-%M-%S').log"
hilfe() {
cat <<'ENDE'
Verwendung: update.sh [Optionen]
--version vX.Y.Z Auf diese Version aktualisieren statt auf die neueste
--ja Ohne Rückfrage durchlaufen
--ohne-backup Keine Sicherung anlegen (nicht empfohlen)
--nur-pruefen Nur zeigen, was passieren würde
--wartezeit <s> Sekunden, die auf einen gesunden Start gewartet wird
(Standard: 180)
--hilfe Diese Übersicht
Ablauf: Voraussetzungen prüfen, Versionen ermitteln, Änderungen zeigen,
sichern, Ziel-Tag auschecken, bauen, starten, auf /api/health warten.
Schlägt etwas fehl, wird auf die vorherige Version zurückgerollt.
ENDE
}
meldung() { printf '%s %s\n' "$(date '+%H:%M:%S')" "$1" | tee -a "$LOGDATEI"; }
fehler() {
printf '%s Fehler: %s\n' "$(date '+%H:%M:%S')" "$1" | tee -a "$LOGDATEI" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--version)
ZIELVERSION="${2:-}"
[ -n "$ZIELVERSION" ] || fehler '--version braucht eine Angabe wie v0.1.0.'
shift 2
;;
--ja) OHNE_RUECKFRAGE=1; shift ;;
--ohne-backup) OHNE_BACKUP=1; shift ;;
--nur-pruefen) NUR_PRUEFEN=1; shift ;;
--wartezeit)
WARTEZEIT="${2:-}"
case "$WARTEZEIT" in
''|*[!0-9]*) fehler '--wartezeit braucht eine ganze Zahl.' ;;
esac
shift 2
;;
--hilfe|-h) hilfe; exit 0 ;;
*) fehler "Unbekannte Option '$1'. 'update.sh --hilfe' zeigt die Übersicht." ;;
esac
done
mkdir -p "$LOGVERZEICHNIS"
# --- 1. Voraussetzungen ----------------------------------------------------
meldung 'Voraussetzungen prüfen.'
for werkzeug in git docker curl jq; do
command -v "$werkzeug" >/dev/null 2>&1 || fehler "$werkzeug fehlt."
done
docker compose version >/dev/null 2>&1 || fehler 'docker compose fehlt.'
[ -d "$WURZEL/.git" ] || fehler 'Kein Git-Arbeitsbaum – im Projektverzeichnis aufrufen.'
[ -f "$WURZEL/.env" ] || fehler '.env fehlt.'
if [ -n "$(git status --porcelain)" ]; then
fehler 'Der Arbeitsbaum ist nicht sauber. Erst aufräumen oder committen.'
fi
APP_PORT="$(grep -E '^APP_PORT=' .env | tail -1 | cut -d= -f2- || true)"
APP_PORT="${APP_PORT:-3000}"
HEALTH_URL="http://127.0.0.1:${APP_PORT}/api/health"
# --- 2. Versionen ermitteln ------------------------------------------------
meldung 'Versionen ermitteln.'
INSTALLIERT="$(curl -fsS --max-time 5 "$HEALTH_URL" 2>/dev/null | jq -r '.version // empty' || true)"
if [ -z "$INSTALLIERT" ]; then
INSTALLIERT="$(jq -r '.version' package.json)"
meldung "Die laufende Instanz war nicht erreichbar; nehme package.json: $INSTALLIERT"
else
meldung "Installiert: $INSTALLIERT"
fi
VORHER_REF="$(git rev-parse --abbrev-ref HEAD)"
[ "$VORHER_REF" != 'HEAD' ] || VORHER_REF="$(git rev-parse HEAD)"
git fetch --tags --quiet origin || fehler 'git fetch ist fehlgeschlagen.'
if [ -z "$ZIELVERSION" ]; then
ZIELVERSION="$(git tag --list 'v*' --sort=-v:refname | head -1)"
[ -n "$ZIELVERSION" ] || fehler 'Es gibt keinen Versions-Tag auf origin.'
fi
git rev-parse -q --verify "refs/tags/${ZIELVERSION}" >/dev/null \
|| fehler "Den Tag $ZIELVERSION gibt es nicht."
meldung "Ziel: $ZIELVERSION"
if [ "v$INSTALLIERT" = "$ZIELVERSION" ]; then
meldung 'Die Zielversion läuft bereits. Nichts zu tun.'
exit 0
fi
# --- 3. Änderungen zeigen --------------------------------------------------
if [ -f CHANGELOG.md ]; then
meldung "Änderungen bis $ZIELVERSION laut CHANGELOG.md:"
# Vom Abschnitt der Zielversion bis zum Abschnitt der installierten Version.
awk -v ziel="${ZIELVERSION#v}" -v alt="$INSTALLIERT" '
$0 ~ "^## \\[" ziel "\\]" { zeigen = 1 }
zeigen && $0 ~ "^## \\[" alt "\\]" { exit }
zeigen { print " " $0 }
' CHANGELOG.md | tee -a "$LOGDATEI"
fi
if [ "$NUR_PRUEFEN" -eq 1 ]; then
meldung "Nur geprüft: $INSTALLIERT würde auf ${ZIELVERSION#v} aktualisiert."
exit 0
fi
if [ "$OHNE_RUECKFRAGE" -eq 0 ]; then
printf 'Jetzt auf %s aktualisieren? [j/N] ' "$ZIELVERSION"
read -r antwort
case "$antwort" in
[jJ]|[jJ][aA]) ;;
*) meldung 'Abgebrochen.'; exit 0 ;;
esac
fi
# --- 4. Sicherung ----------------------------------------------------------
SICHERUNG=''
if [ "$OHNE_BACKUP" -eq 0 ]; then
meldung 'Sicherung anlegen.'
./backup.sh --tage 30 | tee -a "$LOGDATEI"
SICHERUNG="$(find "$WURZEL/sicherungen" -maxdepth 1 -name 'planer-db-*.dump' -print0 2>/dev/null \
| xargs -0 ls -t 2>/dev/null | head -1 || true)"
else
meldung 'Sicherung übersprungen (--ohne-backup).'
fi
zurueckrollen() {
meldung "Zurückrollen auf $VORHER_REF."
git checkout --quiet "$VORHER_REF" || meldung 'Das Auschecken der alten Version schlug fehl.'
docker compose build >>"$LOGDATEI" 2>&1 || meldung 'Der Neubau der alten Version schlug fehl.'
docker compose up -d >>"$LOGDATEI" 2>&1 || meldung 'Der Start der alten Version schlug fehl.'
if [ -n "$SICHERUNG" ]; then
meldung "Die Datenbank wurde NICHT zurückgespielt. Sicherung: $SICHERUNG"
meldung 'Wiederherstellen mit:'
meldung " docker compose exec -T db pg_restore -U planer -d planer --clean --if-exists < \"$SICHERUNG\""
fi
meldung "Vollständiges Protokoll: $LOGDATEI"
}
# --- 5. Auschecken, bauen, starten ----------------------------------------
meldung "Auf $ZIELVERSION wechseln."
if ! git checkout --quiet "$ZIELVERSION"; then
fehler "Der Wechsel auf $ZIELVERSION schlug fehl."
fi
meldung 'Abbild bauen.'
if ! docker compose build >>"$LOGDATEI" 2>&1; then
meldung 'Der Bau ist fehlgeschlagen.'
zurueckrollen
exit 1
fi
meldung 'Dienste starten.'
if ! docker compose up -d >>"$LOGDATEI" 2>&1; then
meldung 'Der Start ist fehlgeschlagen.'
zurueckrollen
exit 1
fi
# --- 6. Auf Gesundheit warten ---------------------------------------------
meldung "Auf $HEALTH_URL warten (bis zu ${WARTEZEIT}s)."
ERWARTET="${ZIELVERSION#v}"
VERGANGEN=0
GESUND=0
while [ "$VERGANGEN" -lt "$WARTEZEIT" ]; do
ANTWORT="$(curl -fsS --max-time 5 "$HEALTH_URL" 2>/dev/null || true)"
if [ -n "$ANTWORT" ]; then
STATUS="$(printf '%s' "$ANTWORT" | jq -r '.status // empty')"
LAEUFT="$(printf '%s' "$ANTWORT" | jq -r '.version // empty')"
if [ "$STATUS" = 'ok' ] && [ "$LAEUFT" = "$ERWARTET" ]; then
GESUND=1
break
fi
fi
sleep 5
VERGANGEN=$((VERGANGEN + 5))
done
# --- 7. Ergebnis -----------------------------------------------------------
if [ "$GESUND" -eq 0 ]; then
meldung "Die neue Version meldet sich nicht gesund mit Version $ERWARTET."
docker compose logs --tail 50 app >>"$LOGDATEI" 2>&1 || true
zurueckrollen
exit 1
fi
meldung "Fertig. Der Planer läuft in Version $ERWARTET."
meldung "Protokoll: $LOGDATEI"