#!/usr/bin/env bash # ===================================================================== # TeamVis — Update einer Self-Host-Instanz # ===================================================================== # Aktualisiert eine bestehende Instanz in einem Rutsch: # 1. Pre-Update-Backup der Datenbank (Modus 2) # 2. `git pull` im Bundle → neue Migrationen + Skripte # 3. Fehlende Migrationen einspielen — jede in EINER Transaktion zusammen # mit ihrem Eintrag in public.teamvis_schema_migrations (Modus 2) # 4. neues App-Image ziehen + Container neu starten # # Warum ein Ledger in der DB? Früher diente `git diff OLD NEW` als Ledger. # Schlug dabei eine Migration fehl, war HEAD trotzdem schon vorgerückt — der # nächste Lauf meldete „keine neuen Migrationen", die restlichen fehlten # dauerhaft, und eine halb angewendete Migration blieb unbemerkt (Review # 04.10.2026). Jetzt gilt: eingespielt ist, was in der Tabelle steht; eine # fehlgeschlagene Migration rollt komplett zurück und wird beim nächsten # Lauf erneut versucht. # # Bestandsinstanzen ohne Tabelle: Beim ersten Lauf wird sie angelegt und mit # allen Migrationen des Stands VOR dem Pull befüllt — genau die galten nach # der alten Logik als eingespielt. # # Aufruf: bash deploy/selfhost/update.sh [instanz-verzeichnis] # (ohne Argument: das Instanz-Verzeichnis wird automatisch erkannt, wenn # es genau eines gibt.) set -euo pipefail c_bold=$'\033[1m'; c_grn=$'\033[32m'; c_red=$'\033[31m'; c_yel=$'\033[33m'; c_rst=$'\033[0m' say() { printf '%s\n' "$*"; } head() { printf '\n%s%s%s\n' "$c_bold" "$*" "$c_rst"; } ok() { printf '%s✓%s %s\n' "$c_grn" "$c_rst" "$*"; } warn() { printf '%s!%s %s\n' "$c_yel" "$c_rst" "$*"; } die() { printf '%s✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; exit 1; } BUNDLE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" cd "$BUNDLE" [ -d .git ] || die "Kein git-Repo in $BUNDLE — Updates laufen über 'git pull'. Bitte das Bundle per 'git clone' beziehen." command -v docker >/dev/null || die "docker fehlt." # ── Instanz-Verzeichnis bestimmen (enthält docker-compose.yml + .env) ── INSTANCE="${1:-}" if [ -z "$INSTANCE" ]; then mapfile -t cands < <( for d in "$BUNDLE"/*/; do [ -f "${d}docker-compose.yml" ] && [ -f "${d}.env" ] && printf '%s\n' "${d%/}" done ) if [ "${#cands[@]}" -eq 1 ]; then INSTANCE="${cands[0]}" elif [ "${#cands[@]}" -eq 0 ]; then die "Kein Instanz-Verzeichnis gefunden. Bitte als Argument angeben: bash deploy/selfhost/update.sh " else say "Mehrere Instanzen gefunden:"; printf ' %s\n' "${cands[@]}" die "Bitte das gewünschte Verzeichnis als Argument angeben." fi fi [ -f "$INSTANCE/docker-compose.yml" ] || die "In $INSTANCE liegt keine docker-compose.yml." ok "Instanz: $INSTANCE" # Modus erkennen: eigener db-Service (Modus 2) oder externe Supabase (Modus 1). MODE2=0 grep -qE '^[[:space:]]+db:' "$INSTANCE/docker-compose.yml" && MODE2=1 POSTGRES_DB="$(grep -E '^POSTGRES_DB=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)" POSTGRES_DB="${POSTGRES_DB:-postgres}" PGPW="$(grep -E '^POSTGRES_PASSWORD=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)" dbsql() { ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db psql -v ON_ERROR_STOP=1 -U supabase_admin -d "$POSTGRES_DB" "$@" ); } if [ -n "${TEAMVIS_UPDATE_REEXEC:-}" ]; then # Zweiter Durchlauf nach dem Selbst-Neustart (siehe Ende von Schritt 2): # Sicherung und git pull sind schon erledigt. OLD="$TEAMVIS_UPDATE_OLD" NEW="$(git rev-parse HEAD)" ok "Neue Fassung von update.sh übernommen — weiter ab Schritt 3." else # ── 1. Pre-Update-Backup (nur Modus 2 — lokale DB) ──────────────────── if [ "$MODE2" = "1" ]; then head "1) Datenbank-Backup" ts="$(date +%Y%m%d-%H%M%S)" dump="$INSTANCE/backup-db-$ts.sql" # Der Dump enthält alle Personendaten — nur für den Eigentümer lesbar # anlegen (vorher landete er mit 644 im Instanz-Verzeichnis). install -m 600 /dev/null "$dump" if ( cd "$INSTANCE" && docker compose exec -T -e PGPASSWORD="$PGPW" db pg_dump -U supabase_admin "$POSTGRES_DB" ) > "$dump" 2>/dev/null; then ok "Backup: $dump ($(wc -l < "$dump") Zeilen)" else warn "Backup fehlgeschlagen — trotzdem fortfahren? (Strg-C zum Abbrechen)"; read -r _ || true fi else warn "Externe Supabase (Modus 1): Backup macht dein Supabase-Anbieter. Kein lokaler Dump." fi # ── 2. git pull → neue Migrationen/Skripte ──────────────────────────── head "2) Bundle aktualisieren (git pull)" OLD="$(git rev-parse HEAD)" git pull --ff-only origin main || die "git pull fehlgeschlagen (abweichende Branches?). Notfalls: git fetch && git reset --hard origin/main" NEW="$(git rev-parse HEAD)" if [ "$OLD" = "$NEW" ]; then ok "Schon aktuell (keine neuen Commits)." else ok "Aktualisiert: ${OLD:0:8} → ${NEW:0:8}" fi # Hat der Pull update.sh selbst geändert, läuft ab hier die NEUE Fassung # weiter — sonst griffen Korrekturen am Skript erst beim nächsten Update # (Okt. 2026: Instanzen mussten update.sh deshalb mehrfach starten). if [ "$OLD" != "$NEW" ] && ! git diff --quiet "$OLD" "$NEW" -- deploy/selfhost/update.sh; then say "update.sh wurde mit aktualisiert — die neue Fassung übernimmt." exec env TEAMVIS_UPDATE_REEXEC=1 TEAMVIS_UPDATE_OLD="$OLD" bash "$BUNDLE/deploy/selfhost/update.sh" "$INSTANCE" fi fi # ── 3. Fehlende Migrationen einspielen ──────────────────────────────── head "3) Migrationen" if [ "$MODE2" = "1" ]; then LEDGER="public.teamvis_schema_migrations" if [ "$(dbsql -tAc "select to_regclass('$LEDGER') is not null")" != "t" ]; then say "Migrations-Ledger fehlt — wird angelegt und mit dem bisherigen Stand (${OLD:0:8}) befüllt." { echo "begin;" echo "create table $LEDGER (filename text primary key, applied_at timestamptz not null default now());" echo "revoke all on $LEDGER from anon, authenticated;" git ls-tree --name-only "$OLD" -- supabase/migrations/ | grep -E '\.sql$' | sort \ | sed -E "s#^supabase/migrations/(.*)\$#insert into $LEDGER (filename) values ('\\1');#" echo "commit;" } | dbsql >/dev/null ok "Ledger angelegt ($(dbsql -tAc "select count(*) from $LEDGER") Einträge)." fi mapfile -t APPLIED < <(dbsql -tAc "select filename from $LEDGER") declare -A DONE=() for f in "${APPLIED[@]}"; do [ -n "$f" ] && DONE["$f"]=1; done PENDING=() for m in $(ls supabase/migrations/*.sql | sort); do [ -z "${DONE[$(basename "$m")]:-}" ] && PENDING+=("$m") done if [ "${#PENDING[@]}" -eq 0 ]; then ok "Keine offenen Migrationen." else for m in "${PENDING[@]}"; do name="$(basename "$m")" say "→ $name" # Migration + Ledger-Eintrag atomar: scheitert die Migration, bleibt # weder ein halber Schema-Stand noch ein Eintrag zurück. { cat "$BUNDLE/$m"; printf "\ninsert into %s (filename) values ('%s');\n" "$LEDGER" "$name"; } \ | dbsql --single-transaction >/dev/null \ || die "Migration $name fehlgeschlagen und vollständig zurückgerollt. Fehler beheben und update.sh erneut starten — bereits eingespielte Migrationen bleiben erhalten, die App wurde NICHT aktualisiert." done ok "${#PENDING[@]} Migration(en) eingespielt." fi else mapfile -t NEWMIG < <(git diff --diff-filter=A --name-only "$OLD" "$NEW" -- 'supabase/migrations/*.sql' 2>/dev/null | sort) if [ "${#NEWMIG[@]}" -eq 0 ]; then ok "Keine neuen Migrationen in diesem Update." else warn "Externe Supabase: bitte diese neuen Migrationen EINMAL in Supabase Studio (SQL-Editor) ausführen:" for m in "${NEWMIG[@]}"; do say " $BUNDLE/$m"; done warn "Schlägt eine fehl: nicht weitermachen, sondern Fehler beheben — update.sh zeigt sie beim nächsten Lauf NICHT erneut an." read -r -p " Enter drücken, sobald sie eingespielt sind … " _ || true fi fi # ── 4. Neues Image ziehen + Container neu starten ───────────────────── head "4) App aktualisieren" # Image-Version: Steht in der .env eine feste, ältere Version (frühere # Installer, oder bewusst gepinnt), zöge `docker compose pull` immer wieder # dasselbe alte Image — die App bliebe trotz neuer Migrationen stehen (RZHAS, # 10.10.2026). Daher auf die Version des Bundles heben. `latest` bleibt # `latest`, eine bewusst neuere Version bleibt unangetastet. # (Kein `| head` in diesem Skript: head() ist oben als Überschriften-Funktion # definiert und überdeckt den Befehl — daher grep -m1.) BUNDLE_VERSION="$(grep -m1 -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$BUNDLE/package.json" | sed 's/.*"\([^"]*\)"$/\1/')" CUR_VERSION="$(grep -E '^TEAMVIS_VERSION=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)" if [ -n "$CUR_VERSION" ] && [ -n "$BUNDLE_VERSION" ] && [ "$CUR_VERSION" != "latest" ] && [ "$CUR_VERSION" != "$BUNDLE_VERSION" ] \ && [ "$(printf '%s\n%s\n' "$CUR_VERSION" "$BUNDLE_VERSION" | sort -V | tail -1)" = "$BUNDLE_VERSION" ]; then sed -i "s/^TEAMVIS_VERSION=.*/TEAMVIS_VERSION=$BUNDLE_VERSION/" "$INSTANCE/.env" ok "Image-Version in .env: $CUR_VERSION → $BUNDLE_VERSION" fi grep -q 'teamvis:\${TEAMVIS_VERSION' "$INSTANCE/docker-compose.yml" \ || warn "Das App-Image steht fest in docker-compose.yml (ohne \${TEAMVIS_VERSION}) — bitte dort von Hand auf ${BUNDLE_VERSION:-die neue Version} setzen." ( cd "$INSTANCE" && docker compose pull && docker compose up -d ) ok "Container neu gestartet." head "Fertig 🎉" SITE_URL="$(grep -m1 -E '^SITE_URL=|^NEXT_PUBLIC_SITE_URL=' "$INSTANCE/.env" 2>/dev/null | cut -d= -f2- || true)" [ -n "$SITE_URL" ] && say " $SITE_URL" say " Bei Problemen Logs prüfen: ( cd $INSTANCE && docker compose logs -f app )"