diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6250b2 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/backup.sh b/backup.sh new file mode 100755 index 0000000..ac20caf --- /dev/null +++ b/backup.sh @@ -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 Ablageort der Sicherung (Standard: ./sicherungen) + --tage Aufbewahrung in Tagen (Standard: 30, 0 = unbegrenzt) + --nur-datenbank Nur den Datenbank-Dump erzeugen + --hilfe Diese Übersicht + +Erzeugt je Lauf zwei Dateien: + planer-db-.dump Datenbank im Custom-Format von pg_dump + planer-dateien-.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.' diff --git a/docker-compose.yml b/docker-compose.yml index 34b0ecd..765b507 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,15 +20,36 @@ services: retries: 10 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: build: context: . dockerfile: Dockerfile + target: betrieb container_name: planer-app restart: unless-stopped depends_on: db: condition: service_healthy + migration: + condition: service_completed_successfully environment: NODE_ENV: production TZ: ${TZ:-Europe/Berlin} @@ -45,21 +66,10 @@ services: WEBAUTHN_RP_ID: ${WEBAUTHN_RP_ID:?WEBAUTHN_RP_ID fehlt in .env} WEBAUTHN_RP_NAME: ${WEBAUTHN_RP_NAME:-Planer} WEBAUTHN_ORIGIN: ${WEBAUTHN_ORIGIN:?WEBAUTHN_ORIGIN fehlt in .env} + # Der Demo-Modus ist im Betrieb nie erlaubt. DEMO_MODUS: 'false' ports: - '${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: db-daten: diff --git a/prisma.config.ts b/prisma.config.ts index 2774414..8ccc8cf 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,5 +1,5 @@ import 'dotenv/config' -import { defineConfig, env } from 'prisma/config' +import { defineConfig } from 'prisma/config' export default defineConfig({ schema: 'prisma/schema.prisma', @@ -8,6 +8,10 @@ export default defineConfig({ seed: 'tsx prisma/seed.ts', }, 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 ?? '', }, }) diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..fc68be5 --- /dev/null +++ b/public/.gitkeep @@ -0,0 +1 @@ +# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann. diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..e0a60a8 --- /dev/null +++ b/scripts/release.sh @@ -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 + + 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): committen und Tag v 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" diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts index b36beeb..6690451 100644 --- a/src/server/auth/index.ts +++ b/src/server/auth/index.ts @@ -3,8 +3,11 @@ import NextAuth from 'next-auth' import { authKonfiguration } from '@/server/auth/config' /** - * Einstiegspunkt fuer Auth.js. Die Konfiguration wird erst hier ausgewertet, - * damit fehlende Umgebungsvariablen beim Start auffallen und nicht beim - * Einlesen des Moduls. + * Einstiegspunkt für Auth.js. + * + * 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()) diff --git a/src/server/db.ts b/src/server/db.ts index b5170ec..bd22821 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -3,13 +3,19 @@ import { PrismaPg } from '@prisma/adapter-pg' import { PrismaClient } from '@/generated/prisma/client' /** - * Ein einziger Prisma-Client je Prozess. Im Entwicklungsbetrieb ueberlebt er - * das Neuladen der Module, damit nicht bei jedem Speichern ein neuer - * Verbindungspool entsteht. + * Ein einziger Prisma-Client je Prozess. + * + * 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 { - prisma: PrismaClient | undefined + planerPrisma: PrismaClient | undefined } function erzeugen(): PrismaClient { @@ -17,15 +23,25 @@ function erzeugen(): PrismaClient { if (!verbindung) { throw new Error('DATABASE_URL ist nicht gesetzt.') } - const adapter = new PrismaPg({ connectionString: verbindung }) return new PrismaClient({ - adapter, + adapter: new PrismaPg({ connectionString: verbindung }), log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], }) } -export const db: PrismaClient = globalerBereich.prisma ?? erzeugen() - -if (process.env.NODE_ENV !== 'production') { - globalerBereich.prisma = db +function klient(): PrismaClient { + globalerBereich.planerPrisma ??= erzeugen() + return globalerBereich.planerPrisma } + +export const db: PrismaClient = new Proxy({} as PrismaClient, { + get(_ziel, eigenschaft) { + const echt = klient() as unknown as Record + 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 + }, +}) diff --git a/update.sh b/update.sh new file mode 100755 index 0000000..35d577a --- /dev/null +++ b/update.sh @@ -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 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"