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
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1
# ---------------------------------------------------------------------------
# Planer – Abbild für den Betrieb.
#
# Die Stufe "bauen" dient zugleich als Abbild für den Migrationsdienst: die
# Prisma-CLI bringt eine ganze Reihe Module mit, die im schlanken
# Laufzeitabbild nichts zu suchen haben. Siehe docker-compose.yml.
# ---------------------------------------------------------------------------
FROM node:24-alpine AS abhaengigkeiten
WORKDIR /app
RUN apk add --no-cache libc6-compat
COPY package.json package-lock.json ./
# Die Bauwerkzeuge werden gebraucht und bleiben in dieser Stufe.
RUN npm ci --ignore-scripts && npm rebuild prisma @prisma/engines esbuild unrs-resolver
FROM node:24-alpine AS bauen
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=abhaengigkeiten /app/node_modules ./node_modules
COPY . .
# Der Prisma-Client wird erzeugt, bevor Next baut.
RUN npx prisma generate && npm run build
FROM node:24-alpine AS betrieb
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0 \
TZ=Europe/Berlin
RUN apk add --no-cache tzdata curl \
&& addgroup --system --gid 1001 planer \
&& adduser --system --uid 1001 --ingroup planer planer
# Die standalone-Ausgabe bringt nur die wirklich benutzten Module mit.
COPY --from=bauen --chown=planer:planer /app/.next/standalone ./
COPY --from=bauen --chown=planer:planer /app/.next/static ./.next/static
COPY --from=bauen --chown=planer:planer /app/public ./public
USER planer
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
CMD curl -fsS http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "server.js"]
Executable
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Sichert Datenbank, .env und Uploads des Planers.
# Wiederherstellung: siehe README.md, Abschnitt "Sicherung und Wiederherstellung".
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$WURZEL"
ZIEL="${WURZEL}/sicherungen"
AUFBEWAHRUNG_TAGE=30
NUR_DATENBANK=0
hilfe() {
cat <<'ENDE'
Verwendung: backup.sh [Optionen]
--ziel <Pfad> Ablageort der Sicherung (Standard: ./sicherungen)
--tage <Anzahl> Aufbewahrung in Tagen (Standard: 30, 0 = unbegrenzt)
--nur-datenbank Nur den Datenbank-Dump erzeugen
--hilfe Diese Übersicht
Erzeugt je Lauf zwei Dateien:
planer-db-<Zeitstempel>.dump Datenbank im Custom-Format von pg_dump
planer-dateien-<Zeitstempel>.tar.gz .env und der Ordner uploads
ENDE
}
meldung() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"; }
fehler() {
printf '%s Fehler: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >&2
exit 1
}
while [ $# -gt 0 ]; do
case "$1" in
--ziel)
ZIEL="${2:-}"
[ -n "$ZIEL" ] || fehler '--ziel braucht einen Pfad.'
shift 2
;;
--tage)
AUFBEWAHRUNG_TAGE="${2:-}"
case "$AUFBEWAHRUNG_TAGE" in
''|*[!0-9]*) fehler '--tage braucht eine ganze Zahl.' ;;
esac
shift 2
;;
--nur-datenbank)
NUR_DATENBANK=1
shift
;;
--hilfe|-h)
hilfe
exit 0
;;
*) fehler "Unbekannte Option '$1'. 'backup.sh --hilfe' zeigt die Übersicht." ;;
esac
done
command -v docker >/dev/null 2>&1 || fehler 'docker fehlt.'
[ -f "$WURZEL/.env" ] || fehler '.env fehlt – im Projektverzeichnis aufrufen.'
# Nur die benötigten Werte lesen, nicht die ganze Datei ausführen.
lies_env() {
local schluessel="$1" standard="${2:-}"
local wert
wert="$(grep -E "^${schluessel}=" "$WURZEL/.env" | tail -1 | cut -d= -f2- || true)"
wert="${wert%\"}"
wert="${wert#\"}"
printf '%s' "${wert:-$standard}"
}
PG_BENUTZER="$(lies_env POSTGRES_USER planer)"
PG_DATENBANK="$(lies_env POSTGRES_DB planer)"
DB_CONTAINER="$(docker compose ps -q db)"
[ -n "$DB_CONTAINER" ] || fehler 'Der Datenbank-Container läuft nicht. Erst "docker compose up -d db".'
ZEITSTEMPEL="$(date '+%Y-%m-%d_%H-%M-%S')"
mkdir -p "$ZIEL"
DUMP="$ZIEL/planer-db-${ZEITSTEMPEL}.dump"
meldung "Datenbank sichern nach $DUMP"
# -Fc ist das Custom-Format: komprimiert und mit pg_restore selektiv einspielbar.
docker exec "$DB_CONTAINER" pg_dump -U "$PG_BENUTZER" -d "$PG_DATENBANK" -Fc > "$DUMP"
if [ ! -s "$DUMP" ]; then
rm -f "$DUMP"
fehler 'Der Dump ist leer geblieben.'
fi
meldung "Datenbank gesichert ($(du -h "$DUMP" | cut -f1))."
if [ "$NUR_DATENBANK" -eq 0 ]; then
DATEIEN="$ZIEL/planer-dateien-${ZEITSTEMPEL}.tar.gz"
meldung "Dateien sichern nach $DATEIEN"
ZU_SICHERN=(.env)
[ -d "$WURZEL/uploads" ] && ZU_SICHERN+=(uploads)
tar -czf "$DATEIEN" -C "$WURZEL" "${ZU_SICHERN[@]}"
meldung "Dateien gesichert ($(du -h "$DATEIEN" | cut -f1))."
fi
if [ "$AUFBEWAHRUNG_TAGE" -gt 0 ]; then
meldung "Sicherungen älter als $AUFBEWAHRUNG_TAGE Tage entfernen."
find "$ZIEL" -maxdepth 1 -type f -name 'planer-*' -mtime "+$AUFBEWAHRUNG_TAGE" -print -delete
fi
meldung 'Sicherung abgeschlossen.'
+22 -12
View File
@@ -20,15 +20,36 @@ services:
retries: 10 retries: 10
start_period: 20s start_period: 20s
# Einmaldienst: spielt die Migrationen ein und beendet sich. Läuft aus der
# Bau-Stufe, weil die Prisma-CLI eine Reihe Module braucht, die im schlanken
# Laufzeitabbild nichts zu suchen haben.
migration:
build:
context: .
dockerfile: Dockerfile
target: bauen
container_name: planer-migration
restart: 'no'
depends_on:
db:
condition: service_healthy
environment:
TZ: ${TZ:-Europe/Berlin}
DATABASE_URL: postgresql://${POSTGRES_USER:-planer}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-planer}?schema=public
command: node node_modules/prisma/build/index.js migrate deploy
app: app:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
target: betrieb
container_name: planer-app container_name: planer-app
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
migration:
condition: service_completed_successfully
environment: environment:
NODE_ENV: production NODE_ENV: production
TZ: ${TZ:-Europe/Berlin} TZ: ${TZ:-Europe/Berlin}
@@ -45,21 +66,10 @@ services:
WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env} WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env}
WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer} WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer}
WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env} WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env}
# Der Demo-Modus ist im Betrieb nie erlaubt.
DEMO_MODUS: 'false' DEMO_MODUS: 'false'
ports: ports:
- '${APP_PORT:-3000}:3000' - '${APP_PORT:-3000}:3000'
healthcheck:
test:
[
'CMD',
'node',
'-e',
"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
volumes: volumes:
db-daten: db-daten:
+6 -2
View File
@@ -1,5 +1,5 @@
import 'dotenv/config' import 'dotenv/config'
import { defineConfig, env } from 'prisma/config' import { defineConfig } from 'prisma/config'
export default defineConfig({ export default defineConfig({
schema: 'prisma/schema.prisma', schema: 'prisma/schema.prisma',
@@ -8,6 +8,10 @@ export default defineConfig({
seed: 'tsx prisma/seed.ts', seed: 'tsx prisma/seed.ts',
}, },
datasource: { datasource: {
url: env('DATABASE_URL'), // Bewusst über process.env statt über den env()-Helfer: "prisma generate"
// läuft auch beim Bauen des Abbilds, wo es noch gar keine Datenbank gibt.
// env() bricht dort ab. Fehlt die URL für migrate oder studio, meldet
// Prisma das selbst.
url: process.env.DATABASE_URL ?? '',
}, },
}) })
+1
View File
@@ -0,0 +1 @@
# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann.
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# Setzt eine neue Version: package.json, CHANGELOG, Commit und Tag.
# Pusht bewusst nicht – das geschieht erst nach ausdrücklicher Freigabe.
set -euo pipefail
WURZEL="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$WURZEL"
meldung() { printf '▸ %s\n' "$1"; }
fehler() {
printf 'Fehler: %s\n' "$1" >&2
exit 1
}
hilfe() {
cat <<'ENDE'
Verwendung: scripts/release.sh <version>
<version> Semantische Version ohne führendes v, z. B. 0.1.0
Ablauf:
1. Sauberen Arbeitsbaum und grüne Prüfungen sicherstellen
2. Version in package.json setzen
3. "Unveröffentlicht" im CHANGELOG in einen datierten Abschnitt überführen
4. chore(release): <version> committen und Tag v<version> anlegen
Es wird nicht gepusht.
ENDE
}
VERSION="${1:-}"
case "$VERSION" in
''|--hilfe|-h) hilfe; [ -z "$VERSION" ] && exit 1; exit 0 ;;
esac
printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \
|| fehler "\"$VERSION\" sieht nicht nach einer semantischen Version aus (z. B. 0.1.0)."
TAG="v$VERSION"
# --- 1. Voraussetzungen ----------------------------------------------------
meldung 'Arbeitsbaum prüfen.'
[ -d .git ] || fehler 'Kein Git-Arbeitsbaum.'
[ -z "$(git status --porcelain)" ] || fehler 'Der Arbeitsbaum ist nicht sauber.'
git rev-parse -q --verify "refs/tags/$TAG" >/dev/null \
&& fehler "Den Tag $TAG gibt es schon."
[ -f CHANGELOG.md ] || fehler 'CHANGELOG.md fehlt.'
grep -q '^## \[Unveröffentlicht\]' CHANGELOG.md \
|| fehler 'Im CHANGELOG fehlt der Abschnitt "## [Unveröffentlicht]".'
INHALT="$(awk '
/^## \[Unveröffentlicht\]/ { sammeln = 1; next }
sammeln && /^## / { exit }
sammeln { print }
' CHANGELOG.md | grep -v '^[[:space:]]*$' || true)"
[ -n "$INHALT" ] || fehler 'Der Abschnitt "Unveröffentlicht" ist leer.'
meldung 'Prüfungen laufen lassen.'
npm run pruefen || fehler 'Die Prüfungen sind nicht grün.'
# --- 2. Version setzen -----------------------------------------------------
meldung "Version auf $VERSION setzen."
npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null
# --- 3. CHANGELOG ----------------------------------------------------------
meldung 'CHANGELOG fortschreiben.'
HEUTE="$(date '+%Y-%m-%d')"
TEMP="$(mktemp)"
awk -v version="$VERSION" -v heute="$HEUTE" '
/^## \[Unveröffentlicht\]/ {
print "## [Unveröffentlicht]"
print ""
print "## [" version "] – " heute
next
}
{ print }
' CHANGELOG.md > "$TEMP"
mv "$TEMP" CHANGELOG.md
npx prettier --write package.json CHANGELOG.md >/dev/null 2>&1 || true
# --- 4. Commit und Tag -----------------------------------------------------
meldung "Commit und Tag $TAG anlegen."
git add package.json package-lock.json CHANGELOG.md
git commit -m "chore(release): $VERSION"
git tag -a "$TAG" -m "Planer $VERSION"
meldung "Fertig. $TAG ist angelegt, aber nicht gepusht."
printf '\nNach der Freigabe:\n'
printf ' git push origin %s\n' "$(git rev-parse --abbrev-ref HEAD)"
printf ' git push origin %s\n' "$TAG"
+7 -4
View File
@@ -3,8 +3,11 @@ import NextAuth from 'next-auth'
import { authKonfiguration } from '@/server/auth/config' import { authKonfiguration } from '@/server/auth/config'
/** /**
* Einstiegspunkt fuer Auth.js. Die Konfiguration wird erst hier ausgewertet, * Einstiegspunkt für Auth.js.
* damit fehlende Umgebungsvariablen beim Start auffallen und nicht beim *
* Einlesen des Moduls. * Die Konfiguration wird als Funktion übergeben und dadurch erst bei der
* ersten Anfrage ausgewertet. Sie prüft Umgebungsvariablen, die es beim Bauen
* des Abbilds noch nicht gibt – bei sofortiger Auswertung bräche der Bau ab,
* sobald Next die Seitendaten der Auth-Routen sammelt.
*/ */
export const { handlers, auth, signIn, signOut } = NextAuth(authKonfiguration()) export const { handlers, auth, signIn, signOut } = NextAuth(() => authKonfiguration())
+26 -10
View File
@@ -3,13 +3,19 @@ import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@/generated/prisma/client' import { PrismaClient } from '@/generated/prisma/client'
/** /**
* Ein einziger Prisma-Client je Prozess. Im Entwicklungsbetrieb ueberlebt er * Ein einziger Prisma-Client je Prozess.
* das Neuladen der Module, damit nicht bei jedem Speichern ein neuer *
* Verbindungspool entsteht. * Der Client wird erst beim ersten Zugriff erzeugt, nicht beim Laden des
* Moduls. Beim Bauen des Abbilds gibt es noch keine DATABASE_URL – Next sammelt
* dabei aber Seitendaten und lädt dieses Modul. Würde hier sofort verbunden,
* bräche der Bau ab.
*
* Im Entwicklungsbetrieb überlebt der Client das Neuladen der Module, damit
* nicht bei jedem Speichern ein neuer Verbindungspool entsteht.
*/ */
const globalerBereich = globalThis as unknown as { const globalerBereich = globalThis as unknown as {
prisma: PrismaClient | undefined planerPrisma: PrismaClient | undefined
} }
function erzeugen(): PrismaClient { function erzeugen(): PrismaClient {
@@ -17,15 +23,25 @@ function erzeugen(): PrismaClient {
if (!verbindung) { if (!verbindung) {
throw new Error('DATABASE_URL ist nicht gesetzt.') throw new Error('DATABASE_URL ist nicht gesetzt.')
} }
const adapter = new PrismaPg({ connectionString: verbindung })
return new PrismaClient({ return new PrismaClient({
adapter, adapter: new PrismaPg({ connectionString: verbindung }),
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
}) })
} }
export const db: PrismaClient = globalerBereich.prisma ?? erzeugen() function klient(): PrismaClient {
globalerBereich.planerPrisma ??= erzeugen()
if (process.env.NODE_ENV !== 'production') { return globalerBereich.planerPrisma
globalerBereich.prisma = db
} }
export const db: PrismaClient = new Proxy({} as PrismaClient, {
get(_ziel, eigenschaft) {
const echt = klient() as unknown as Record<string | symbol, unknown>
const wert = echt[eigenschaft]
// Methoden an den echten Client binden, sonst zeigt "this" auf den Proxy.
if (typeof wert === 'function') {
return (wert as (...argumente: unknown[]) => unknown).bind(echt)
}
return wert
},
})
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"