#!/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" "$@" ); } # ── 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 # ── 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" ( cd "$INSTANCE" && docker compose pull && docker compose up -d ) ok "Container neu gestartet." head "Fertig 🎉" SITE_URL="$(grep -E '^SITE_URL=|^NEXT_PUBLIC_SITE_URL=' "$INSTANCE/.env" 2>/dev/null | head -1 | cut -d= -f2-)" [ -n "$SITE_URL" ] && say " $SITE_URL" say " Bei Problemen Logs prüfen: ( cd $INSTANCE && docker compose logs -f app )"