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:
co-authored by
Claude Opus 5
parent
126ad93301
commit
f91d015cc4
+52
@@ -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"]
|
||||
@@ -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
@@ -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:
|
||||
|
||||
+6
-2
@@ -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 ?? '',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Der Ordner bleibt im Git, damit der Docker-Bau ihn kopieren kann.
|
||||
Executable
+93
@@ -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"
|
||||
@@ -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())
|
||||
|
||||
+26
-10
@@ -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<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
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user