Files
teamvis-selfhost/deploy/selfhost/update.sh
T

191 lines
9.8 KiB
Bash
Executable File

#!/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 <verzeichnis>"
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 )"