From f91d015cc46aef87c209509690b4b4078fa03f87 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Thu, 10 Sep 2026 14:08:04 +0200 Subject: [PATCH] feat(betrieb): Abbild, Compose-Stapel und Betriebsskripte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Dockerfile | 52 ++++++++++ backup.sh | 107 ++++++++++++++++++++ docker-compose.yml | 34 ++++--- prisma.config.ts | 8 +- public/.gitkeep | 1 + scripts/release.sh | 93 +++++++++++++++++ src/server/auth/index.ts | 11 ++- src/server/db.ts | 36 +++++-- update.sh | 209 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 523 insertions(+), 28 deletions(-) create mode 100644 Dockerfile create mode 100755 backup.sh create mode 100644 public/.gitkeep create mode 100755 scripts/release.sh create mode 100755 update.sh 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"