Erste drei Punkte aus dem TODO-Block L (Sicherheitsdurchgang vom 12.09.): - scripts/update-app.sh: kein automatischer "prisma db push --accept-data-loss"- Fallback mehr, wenn "migrate deploy" fehlschlaegt. Das lief am 11.09. beim Boot-Lauf tatsaechlich unbeaufsichtigt durch. Ein fehlgeschlagenes Migrate bricht das Deployment jetzt sauber ab, exakt wie ein fehlgeschlagener Build. - 14 API-Routen gaben error.message direkt an den Client zurueck (Kalender- Connect/-Sync, Task-Import/-Sync, Someday-Listen, Datenimport). Neuer Helper src/lib/api-error.ts (safeErrorMessage): generische Meldung in Produktion, echte Meldung nur noch lokal. Logging unveraendert in console.error. - src/lib/email.ts: tls.rejectUnauthorized: false entfernt -- stand seit dem allerersten Commit unbegruendet drin, produktiver SMTP-Host (Kasserver) hat ein gueltiges Zertifikat. Verifiziert: Typecheck, Lint, 79 Unit-Tests, Produktions-Build alle gruen. v1.123.2 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
236 lines
11 KiB
Bash
Executable File
236 lines
11 KiB
Bash
Executable File
#!/bin/bash
|
|
#
|
|
# Deploy script for the production host (WebApp-Testing-live).
|
|
#
|
|
# The app runs there as a PM2 process ("my-weekly-todo") from /root/My-Weekly-ToDo-List.
|
|
# It is NOT run under Docker — deploy.sh and docker-compose.yml in this repo describe an
|
|
# older Portainer setup that is not what serves todo.martin-bierschenk.de.
|
|
#
|
|
# This is the single update path. It is used both ways:
|
|
# - by hand: /root/My-Weekly-ToDo-List/scripts/update-app.sh [--force]
|
|
# - at boot: my-weekly-todo-update.service, before pm2-root.service starts the app
|
|
#
|
|
# There used to be a second, near-identical boot-update.sh. The two drifted (the npm ci fix
|
|
# from 1.114.1 only ever landed in this one), so boot-update.sh is now a shim that calls
|
|
# this script. Keep it that way: one script, one set of fixes.
|
|
#
|
|
# The whole body is wrapped in a function so bash parses it in one go: this script lives
|
|
# inside the very checkout it updates, and `git pull` rewriting it mid-run would otherwise
|
|
# make bash resume at a stale file offset.
|
|
|
|
main() {
|
|
# pipefail matters because everything below is written through a `tee` redirect: without
|
|
# it the shell sees tee's exit status (always 0) and a failed build looks like success.
|
|
set -euo pipefail
|
|
|
|
# pm2 lives in /usr/local/bin. The systemd unit sets this explicitly, but a plain
|
|
# `ssh host 'scripts/update-app.sh'` (no login shell, .bashrc never sourced) does not —
|
|
# it leaves PATH at /usr/bin:/bin and the restart step below dies with "pm2: not found",
|
|
# after the build has already succeeded and switched over. Hit in production 2026-09-12.
|
|
export PATH="/usr/local/bin:$PATH"
|
|
|
|
local APP_DIR="/root/My-Weekly-ToDo-List"
|
|
local PM2_APP="my-weekly-todo"
|
|
local LOG_FILE="/var/log/my-weekly-todo-update.log"
|
|
local LOCK_FILE="/var/lock/my-weekly-todo-update.lock"
|
|
local FORCE="${1:-}"
|
|
|
|
# One redirect for the whole run: goes to the log and, when run by hand, to the console.
|
|
exec > >(tee -a "$LOG_FILE") 2>&1
|
|
|
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"; }
|
|
|
|
# Bei einem Abbruch den halbfertigen Build wegraeumen. Der Hinweis "die laufende App
|
|
# bleibt unberuehrt" stimmt seit v1.120.0 tatsaechlich -- vorher wurde direkt auf .next
|
|
# gebaut, ein Fehlschlag riss die laufende Instanz mit.
|
|
trap 'log "!!! Update FAILED at line $LINENO (exit $?) — die laufende App bleibt unberuehrt"; kill "${SMOKE_PID:-0}" 2>/dev/null || true; rm -rf "${APP_DIR:-.}/.next-build"' ERR
|
|
|
|
# Fail fast instead of hanging on a credential prompt when run without a terminal
|
|
# (systemd has no tty). The host authenticates via a read-only SSH deploy key.
|
|
export GIT_TERMINAL_PROMPT=0
|
|
|
|
# Never let a boot run and a manual run install into the same tree at once: a half-written
|
|
# node_modules is what leaves npm unable to clean up afterwards (ENOTEMPTY on rmdir).
|
|
exec 9>"$LOCK_FILE"
|
|
if ! flock -n 9; then
|
|
log "Another update is already running — nothing to do."
|
|
return 0
|
|
fi
|
|
|
|
cd "$APP_DIR"
|
|
|
|
log "=== Update started ==="
|
|
|
|
log "--- Ensuring main branch ---"
|
|
git checkout main
|
|
|
|
log "--- Fetching from git ---"
|
|
git fetch origin main
|
|
|
|
local LOCAL REMOTE
|
|
LOCAL=$(git rev-parse HEAD)
|
|
REMOTE=$(git rev-parse origin/main)
|
|
|
|
# Gebaut wird, wenn der vorhandene Build nicht zum aktuellen Stand passt.
|
|
#
|
|
# Frueher wurde HEAD mit origin/main verglichen. Das ging schief, sobald jemand vor dem
|
|
# Skript von Hand `git pull` ausfuehrte: Dann waren beide gleich, das Skript sah keinen
|
|
# Grund zu bauen -- und lieferte weiter den alten Build aus, obwohl neuer Code dalag.
|
|
# Massgeblich ist deshalb, aus welchem Commit der vorhandene Build stammt.
|
|
local BUILT_COMMIT=""
|
|
[ -f "$APP_DIR/.next/BUILD_COMMIT" ] && BUILT_COMMIT=$(cat "$APP_DIR/.next/BUILD_COMMIT")
|
|
|
|
local NEEDS_BUILD=0
|
|
[ "$LOCAL" != "$REMOTE" ] && NEEDS_BUILD=1
|
|
[ ! -f "$APP_DIR/.next/standalone/server.js" ] && NEEDS_BUILD=1
|
|
[ "$BUILT_COMMIT" != "$REMOTE" ] && NEEDS_BUILD=1
|
|
[ "$FORCE" = "--force" ] && NEEDS_BUILD=1
|
|
|
|
if [ "$NEEDS_BUILD" -eq 0 ]; then
|
|
log "Vorhandener Build stammt aus ${BUILT_COMMIT:0:7} und passt zum aktuellen Stand."
|
|
log "=== Update finished (no changes) ==="
|
|
return 0
|
|
fi
|
|
|
|
if [ "$LOCAL" != "$REMOTE" ]; then
|
|
log "--- Pulling latest changes ($LOCAL -> $REMOTE) ---"
|
|
git pull origin main
|
|
|
|
# npm ci, not npm install: it installs exactly what package-lock.json specifies and
|
|
# never rewrites it. npm install rewrites the lockfile, which leaves the working tree
|
|
# dirty and makes the *next* git pull abort. npm ci clears node_modules itself; if
|
|
# that ever fails with ENOTEMPTY, remove the directory by hand and rerun.
|
|
log "--- Installing dependencies ---"
|
|
npm ci
|
|
fi
|
|
|
|
# Kein automatischer db-push-Fallback mehr: "--accept-data-loss" darf niemand
|
|
# unbeaufsichtigt aus einem Deploy-Skript heraus zustimmen. Das ist am 11.09. beim
|
|
# Boot-Lauf tatsaechlich ausgeloest worden ("migrate deploy failed, falling back to
|
|
# db push..."), ohne dass irgendjemand das gesehen oder bestaetigt haette. Ein
|
|
# fehlgeschlagenes Migrate bricht das Update jetzt sauber ab -- die laufende App bleibt
|
|
# unveraendert, exakt wie bei einem fehlgeschlagenen Build. Migrationsfehler muessen von
|
|
# Hand untersucht werden, nicht automatisch ueberschrieben.
|
|
log "--- Updating database schema (safe migrate) ---"
|
|
npx prisma migrate deploy
|
|
|
|
log "--- Regenerating Prisma client ---"
|
|
npx prisma generate
|
|
|
|
# ── Build in ein Nebenverzeichnis ────────────────────────────────────────────────
|
|
#
|
|
# Frueher lief `next build` direkt auf .next -- also auf dem Verzeichnis, aus dem die
|
|
# laufende App bedient wird. Scheiterte der Build, war nicht nur das Update weg, sondern
|
|
# die laufende Instanz gleich mit: PM2 fand server.js nicht mehr und geriet in eine
|
|
# Neustartschleife. Genau das ist am 07.09. passiert, die Seite war rund eine Stunde
|
|
# unbrauchbar.
|
|
#
|
|
# Jetzt entsteht der neue Build unter .next-build. Das laufende .next wird dabei nicht
|
|
# angefasst. Erst wenn der neue Build gebaut UND probeweise gestartet werden konnte,
|
|
# wird umgeschaltet.
|
|
#
|
|
# Nodes Standard-Heap lag hier bei rund 1,5 GB und reichte nicht; ein Versuch mit 2048
|
|
# scheiterte punktgenau bei 2045 MB. Gemessener Bedarf sind rund 2,6 GB.
|
|
local BUILD_DIR=".next-build"
|
|
rm -rf "$BUILD_DIR"
|
|
|
|
log "--- Building application (nach $BUILD_DIR) ---"
|
|
NEXT_DIST_DIR="$BUILD_DIR" NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
|
|
|
# next.config.js setzt output: 'standalone', und Next.js kopiert public/ und static/
|
|
# NICHT selbst dorthin -- ein dokumentierter manueller Schritt. Wird er ausgelassen,
|
|
# verweist das neue HTML auf Chunks, die im standalone-Baum fehlen: sie liefern 404,
|
|
# React hydriert nie, und jede Seite haengt auf "Loading session...", waehrend die API
|
|
# weiter 200 antwortet.
|
|
#
|
|
# Der innere Ordner traegt den Namen des distDir -- deshalb .next-build/static und nicht
|
|
# .next/static. Beim Umschalten wandert er unveraendert mit; server.js findet ihn
|
|
# relativ zu sich selbst (geprueft).
|
|
log "--- Copying static assets into standalone output ---"
|
|
cp -r public "$BUILD_DIR/standalone/"
|
|
mkdir -p "$BUILD_DIR/standalone/$BUILD_DIR/static"
|
|
cp -r "$BUILD_DIR/static/." "$BUILD_DIR/standalone/$BUILD_DIR/static/"
|
|
cp .env "$BUILD_DIR/standalone/" 2>/dev/null || true
|
|
|
|
# ── Smoke-Test auf einem freien Port ─────────────────────────────────────────────
|
|
#
|
|
# Ein Build kann durchlaufen und trotzdem nicht starten. Bevor der neue Stand live
|
|
# geht, muss er einmal wirklich geantwortet haben.
|
|
log "--- Smoke-Test des neuen Builds ---"
|
|
local SMOKE_PORT=3999
|
|
|
|
# Steht noch ein Server aus einem abgebrochenen Lauf auf dem Port, wuerde der
|
|
# Smoke-Test dessen Antwort messen statt der des neuen Builds.
|
|
if (ss -ltn 2>/dev/null || netstat -ltn 2>/dev/null) | grep -q ":$SMOKE_PORT "; then
|
|
log "!!! Port $SMOKE_PORT ist belegt -- vermutlich ein Rest aus einem abgebrochenen Lauf."
|
|
log " Bitte pruefen: ss -ltnp | grep $SMOKE_PORT"
|
|
rm -rf "$BUILD_DIR"
|
|
return 1
|
|
fi
|
|
|
|
# exec ist hier wesentlich: Ohne das ersetzt node die Subshell NICHT, $! zeigt auf die
|
|
# Subshell, und der node-Prozess ueberlebt das spaetere kill als Waise -- Port 3999
|
|
# bliebe belegt und jeder weitere Deploy scheiterte daran.
|
|
( cd "$BUILD_DIR/standalone" && exec env PORT=$SMOKE_PORT node server.js > /tmp/smoke-test.log 2>&1 ) &
|
|
local SMOKE_PID=$!
|
|
local SMOKE_OK=0
|
|
for _ in $(seq 1 20); do
|
|
sleep 1
|
|
if curl -sf -o /dev/null --max-time 3 "http://127.0.0.1:$SMOKE_PORT/auth/login"; then
|
|
SMOKE_OK=1
|
|
break
|
|
fi
|
|
done
|
|
kill "$SMOKE_PID" 2>/dev/null || true
|
|
wait "$SMOKE_PID" 2>/dev/null || true
|
|
# Nachfassen, falls der Prozess das TERM ignoriert hat.
|
|
if kill -0 "$SMOKE_PID" 2>/dev/null; then
|
|
sleep 2
|
|
kill -9 "$SMOKE_PID" 2>/dev/null || true
|
|
fi
|
|
|
|
if [ "$SMOKE_OK" -ne 1 ]; then
|
|
log "!!! Smoke-Test fehlgeschlagen -- der neue Build antwortet nicht."
|
|
log " Die laufende App bleibt unveraendert. Ausgabe:"
|
|
tail -20 /tmp/smoke-test.log | sed 's/^/ /'
|
|
rm -rf "$BUILD_DIR"
|
|
return 1
|
|
fi
|
|
log " Smoke-Test bestanden."
|
|
|
|
# ── Umschalten ───────────────────────────────────────────────────────────────────
|
|
#
|
|
# Ab hier ist der neue Build erwiesenermassen lauffaehig. Der alte bleibt als
|
|
# .next-previous liegen; zum Zurueckrollen genuegt:
|
|
# pm2 stop my-weekly-todo
|
|
# rm -rf .next && mv .next-previous .next
|
|
# pm2 restart my-weekly-todo
|
|
log "--- Umschalten auf den neuen Build ---"
|
|
rm -rf .next-previous
|
|
if [ -d .next ]; then
|
|
mv .next .next-previous
|
|
fi
|
|
mv "$BUILD_DIR" .next
|
|
|
|
# Damit der naechste Lauf erkennt, ob der Build noch zum Code passt.
|
|
git rev-parse HEAD > .next/BUILD_COMMIT
|
|
|
|
# At boot this script runs before pm2-root.service, so the PM2 daemon is not up yet and
|
|
# there is nothing to restart — pm2-root.service resurrects the app right after. Probing
|
|
# with `pm2 describe` would be wrong here: any pm2 command spawns a daemon, and a stray
|
|
# one started from this unit would race the real service.
|
|
local PM2_PID_FILE="${HOME:-/root}/.pm2/pm2.pid"
|
|
if [ -f "$PM2_PID_FILE" ] && kill -0 "$(cat "$PM2_PID_FILE")" 2>/dev/null; then
|
|
log "--- Restarting PM2 process ---"
|
|
pm2 restart "$PM2_APP" --update-env
|
|
pm2 save
|
|
else
|
|
log "PM2 daemon is not running — leaving the start to pm2-root.service."
|
|
fi
|
|
|
|
log "=== Update Complete ==="
|
|
log "Deployed version: $(node -p "require('$APP_DIR/package.json').version")"
|
|
}
|
|
|
|
main "$@"
|