- setup.sh: interactive/non-interactive one-shot installer (build, DB healthcheck, migrate, seed, start), idempotent secret generation, NPM reverse-proxy network auto-detection and optional join, optional AUTO_UPDATE cron install. - update.sh: release-tag-gated updates only (never bare main), DB backup with retention before every update, lock file against concurrent runs, automatic code rollback on failed post-update healthcheck. - Dockerfile: multi-stage build, non-root user, built-in HEALTHCHECK against the new /api/health route, wholesale COPY so new source dirs (e.g. scripts/) never silently go missing at runtime. - docker-compose.yml: internal anouma-network (configurable), named volume for Postgres, app depends_on postgres healthy, no unnecessary published ports; docker-compose.override.yml.example documents joining an existing NPM network without ever touching NPM itself. - Fix host-detection: isHost was Boolean(user), wrongly granting host privileges to logged-in customers; now checks user.collection === "users". - Wire configurable STUN/TURN servers through to the WebRTC client (lib/meeting/iceServers.ts) so a TURN server can be added later via env vars only, no code changes. - DEPLOYMENT.md, updated README.md and .env.example documenting the whole flow: NPM integration, env vars, WebRTC, updates, backups, rollback. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
339 lines
14 KiB
Bash
Executable File
339 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ANOUMA — automated setup.
|
||
#
|
||
# Fresh install:
|
||
# git clone https://git.maro.run/maro/anouma.git && cd anouma && ./setup.sh
|
||
#
|
||
# Safe to re-run on an existing installation: never overwrites secrets that
|
||
# already exist in .env, never deletes volumes/networks/containers it
|
||
# doesn't own, and never touches Nginx Proxy Manager (or any other existing
|
||
# container) beyond optionally joining its Docker network.
|
||
set -euo pipefail
|
||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Logging
|
||
# ---------------------------------------------------------------------------
|
||
COLOR_INFO="\033[36m"; COLOR_WARN="\033[33m"; COLOR_ERROR="\033[31m"; COLOR_OK="\033[32m"; COLOR_RESET="\033[0m"
|
||
log_info() { printf "${COLOR_INFO}[INFO]${COLOR_RESET} %s\n" "$1"; }
|
||
log_warn() { printf "${COLOR_WARN}[WARN]${COLOR_RESET} %s\n" "$1"; }
|
||
log_error() { printf "${COLOR_ERROR}[ERROR]${COLOR_RESET} %s\n" "$1" >&2; }
|
||
log_success() { printf "${COLOR_OK}[SUCCESS]${COLOR_RESET} %s\n" "$1"; }
|
||
|
||
fail() {
|
||
log_error "$1"
|
||
[ -n "${2:-}" ] && log_error " → $2"
|
||
exit 1
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Args
|
||
# ---------------------------------------------------------------------------
|
||
NON_INTERACTIVE=false
|
||
SKIP_SEED=false
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--non-interactive) NON_INTERACTIVE=true ;;
|
||
--skip-seed) SKIP_SEED=true ;;
|
||
--help|-h)
|
||
echo "Usage: ./setup.sh [--non-interactive] [--skip-seed]"
|
||
exit 0
|
||
;;
|
||
esac
|
||
done
|
||
|
||
ask() {
|
||
# ask "Prompt" "default" -> echoes the answer
|
||
local prompt="$1" default="$2" answer
|
||
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
|
||
echo "$default"
|
||
return
|
||
fi
|
||
read -r -p "$prompt [$default]: " answer || true
|
||
echo "${answer:-$default}"
|
||
}
|
||
|
||
ask_yn() {
|
||
# ask_yn "Prompt" "Y|N" -> echoes true/false
|
||
local prompt="$1" default="$2" answer
|
||
if [ "$NON_INTERACTIVE" = true ] || [ ! -t 0 ]; then
|
||
[ "$default" = "Y" ] && echo true || echo false
|
||
return
|
||
fi
|
||
local hint="y/N"; [ "$default" = "Y" ] && hint="Y/n"
|
||
read -r -p "$prompt [$hint]: " answer || true
|
||
answer="${answer:-$default}"
|
||
case "$answer" in
|
||
y|Y|yes|Yes|YES) echo true ;;
|
||
*) echo false ;;
|
||
esac
|
||
}
|
||
|
||
echo ""
|
||
echo "ANOUMA Setup"
|
||
echo "============"
|
||
echo ""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1–4: detect tooling
|
||
# ---------------------------------------------------------------------------
|
||
command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden." "Installiere Docker: https://docs.docker.com/engine/install/"
|
||
log_success "Docker erkannt: $(docker --version)"
|
||
|
||
docker info >/dev/null 2>&1 || fail "Der Docker-Daemon läuft nicht (oder fehlende Berechtigung)." "Starte Docker bzw. führe dieses Skript mit einem Nutzer aus, der Docker verwenden darf."
|
||
|
||
COMPOSE=""
|
||
if docker compose version >/dev/null 2>&1; then
|
||
COMPOSE="docker compose"
|
||
elif command -v docker-compose >/dev/null 2>&1; then
|
||
COMPOSE="docker-compose"
|
||
fi
|
||
[ -n "$COMPOSE" ] || fail "Docker Compose wurde nicht gefunden." "Installiere das Compose-Plugin: https://docs.docker.com/compose/install/"
|
||
log_success "Docker Compose erkannt: $($COMPOSE version | head -n1)"
|
||
|
||
if command -v git >/dev/null 2>&1; then
|
||
log_success "Git erkannt: $(git --version)"
|
||
else
|
||
log_warn "Git wurde nicht gefunden — für update.sh (Release-Erkennung) empfohlen, aber für den Betrieb selbst nicht zwingend nötig."
|
||
fi
|
||
|
||
# Node/npm are NOT required on the host — everything runs inside the app
|
||
# container. This is purely informational.
|
||
if command -v node >/dev/null 2>&1; then
|
||
log_info "Node.js auf dem Host erkannt: $(node --version) (wird nicht benötigt — ANOUMA läuft in Docker)"
|
||
else
|
||
log_info "Node.js nicht auf dem Host installiert — kein Problem, ANOUMA läuft vollständig in Docker."
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5–6: detect existing Docker networks, look for a reverse proxy (e.g. NPM)
|
||
# ---------------------------------------------------------------------------
|
||
log_info "Prüfe vorhandene Docker-Netzwerke …"
|
||
EXISTING_NETWORKS="$(docker network ls --format '{{.Name}}' 2>/dev/null || true)"
|
||
|
||
detect_npm_network() {
|
||
# 1) Look for a running container that looks like Nginx Proxy Manager and
|
||
# read the network it's actually attached to.
|
||
local npm_container
|
||
npm_container="$(docker ps --format '{{.Names}}\t{{.Image}}' 2>/dev/null | grep -iE 'nginx-proxy-manager|jc21/nginx-proxy-manager' | head -n1 | cut -f1 || true)"
|
||
if [ -n "$npm_container" ]; then
|
||
docker inspect "$npm_container" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}}{{"\n"}}{{end}}' 2>/dev/null | grep -v '^bridge$' | head -n1
|
||
return
|
||
fi
|
||
# 2) Fall back to a name heuristic among existing networks.
|
||
echo "$EXISTING_NETWORKS" | grep -iE 'nginx-proxy-manager|^npm' | head -n1 || true
|
||
}
|
||
|
||
DETECTED_NPM_NETWORK="$(detect_npm_network || true)"
|
||
NPM_DETECTED=false
|
||
if [ -n "$DETECTED_NPM_NETWORK" ]; then
|
||
NPM_DETECTED=true
|
||
log_success "Nginx Proxy Manager erkannt (Netzwerk: $DETECTED_NPM_NETWORK)"
|
||
else
|
||
log_info "Nginx Proxy Manager wurde nicht erkannt — ANOUMA läuft eigenständig, das ist kein Fehler."
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7–10: .env — create if missing, fill in only missing keys otherwise,
|
||
# generate secrets, never overwrite existing ones
|
||
# ---------------------------------------------------------------------------
|
||
gen_secret() { openssl rand -hex 32; }
|
||
|
||
FRESH_ENV=false
|
||
if [ ! -f .env ]; then
|
||
FRESH_ENV=true
|
||
cp .env.example .env
|
||
log_info ".env aus .env.example erstellt."
|
||
fi
|
||
|
||
env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; }
|
||
env_set() {
|
||
# env_set KEY VALUE — replaces an existing (possibly empty) line, or
|
||
# appends the key if it's missing entirely. Never touches other keys.
|
||
local key="$1" value="$2"
|
||
if grep -qE "^$key=" .env; then
|
||
local tmp; tmp="$(mktemp)"
|
||
awk -v k="$key" -v v="$value" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
|
||
else
|
||
printf '%s=%s\n' "$key" "$value" >> .env
|
||
fi
|
||
}
|
||
# Only fills the key in if it is currently missing or blank — this is what
|
||
# makes secret generation safe to re-run (existing secrets survive updates).
|
||
env_ensure() {
|
||
local key="$1" value="$2"
|
||
local current; current="$(env_get "$key")"
|
||
if [ -z "$current" ]; then
|
||
env_set "$key" "$value"
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
if [ "$FRESH_ENV" = true ]; then
|
||
log_info "Domain für diese Installation (z. B. https://anouma.org):"
|
||
APP_URL="$(ask "Domain" "http://localhost:3000")"
|
||
env_set NEXT_PUBLIC_SERVER_URL "$APP_URL"
|
||
fi
|
||
|
||
env_ensure PAYLOAD_SECRET "$(gen_secret)" && log_info "PAYLOAD_SECRET generiert." || log_info "PAYLOAD_SECRET bereits vorhanden — unverändert."
|
||
env_ensure MEETING_SESSION_SECRET "$(gen_secret)" && log_info "MEETING_SESSION_SECRET generiert." || log_info "MEETING_SESSION_SECRET bereits vorhanden — unverändert."
|
||
env_ensure CRON_SECRET "$(gen_secret)" && log_info "CRON_SECRET generiert." || log_info "CRON_SECRET bereits vorhanden — unverändert."
|
||
|
||
if env_ensure POSTGRES_PASSWORD "$(gen_secret)"; then
|
||
log_info "POSTGRES_PASSWORD generiert."
|
||
# Keep DATABASE_URI in sync with the freshly generated password so the
|
||
# app can actually connect — only rewritten when we just generated the
|
||
# password ourselves, never when POSTGRES_PASSWORD already existed.
|
||
PG_USER="$(env_get POSTGRES_USER)"; PG_USER="${PG_USER:-postgres}"
|
||
PG_DB="$(env_get POSTGRES_DB)"; PG_DB="${PG_DB:-anouma}"
|
||
PG_PASSWORD="$(env_get POSTGRES_PASSWORD)"
|
||
env_set DATABASE_URI "postgresql://${PG_USER}:${PG_PASSWORD}@postgres:5432/${PG_DB}"
|
||
else
|
||
log_info "POSTGRES_PASSWORD bereits vorhanden — unverändert."
|
||
fi
|
||
|
||
if [ "$FRESH_ENV" = true ]; then
|
||
echo ""
|
||
SETUP_SMTP="$(ask_yn "SMTP-Zugangsdaten jetzt eintragen? (sonst später manuell in .env)" "N")"
|
||
if [ "$SETUP_SMTP" = true ]; then
|
||
env_set SMTP_HOST "$(ask "SMTP-Host" "")"
|
||
env_set SMTP_PORT "$(ask "SMTP-Port" "587")"
|
||
env_set SMTP_USER "$(ask "SMTP-Benutzer" "")"
|
||
read -r -s -p "SMTP-Passwort: " smtp_pw || true; echo ""
|
||
env_set SMTP_PASSWORD "$smtp_pw"
|
||
fi
|
||
|
||
if [ "$NPM_DETECTED" = true ]; then
|
||
USE_NPM="$(ask_yn "Nginx Proxy Manager verwenden (Netzwerk beitreten)?" "Y")"
|
||
if [ "$USE_NPM" = true ]; then
|
||
NPM_NETWORK_NAME="$(ask "NPM-Docker-Netzwerk" "$DETECTED_NPM_NETWORK")"
|
||
env_set NPM_NETWORK "$NPM_NETWORK_NAME"
|
||
fi
|
||
fi
|
||
|
||
AUTO_UPDATE_ANSWER="$(ask_yn "Automatische Updates aktivieren?" "N")"
|
||
env_set AUTO_UPDATE "$AUTO_UPDATE_ANSWER"
|
||
fi
|
||
|
||
chmod 600 .env || true
|
||
log_success ".env ist bereit."
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Reverse-proxy network override — generated only when configured/detected,
|
||
# never required. See docker-compose.override.yml.example for the manual
|
||
# equivalent.
|
||
# ---------------------------------------------------------------------------
|
||
NPM_NETWORK_VALUE="$(env_get NPM_NETWORK)"
|
||
if [ -n "$NPM_NETWORK_VALUE" ]; then
|
||
if ! echo "$EXISTING_NETWORKS" | grep -qx "$NPM_NETWORK_VALUE"; then
|
||
log_warn "NPM_NETWORK=$NPM_NETWORK_VALUE ist in .env gesetzt, aber kein Docker-Netzwerk mit diesem Namen existiert — das Override wird trotzdem geschrieben, `docker compose up` wird aber fehlschlagen, bis das Netzwerk existiert."
|
||
fi
|
||
sed "s/\${NPM_NETWORK}/$NPM_NETWORK_VALUE/g" docker-compose.override.yml.example > docker-compose.override.yml
|
||
log_success "docker-compose.override.yml für Reverse-Proxy-Netzwerk '$NPM_NETWORK_VALUE' erstellt."
|
||
elif [ -f docker-compose.override.yml ]; then
|
||
log_info "NPM_NETWORK ist leer, aber docker-compose.override.yml existiert bereits — wird unverändert gelassen (nicht automatisch gelöscht)."
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 9–11: build, start database, wait for healthy, migrate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Waits for a compose service's container healthcheck to report "healthy".
|
||
# Uses `docker inspect` on the container ID (via `compose ps -q`) rather than
|
||
# `compose ps --format`, since Go-template support in `ps --format` varies
|
||
# across Compose versions while `docker inspect` is stable everywhere.
|
||
wait_healthy() {
|
||
local service="$1" timeout_iterations="$2" id status
|
||
for _ in $(seq 1 "$timeout_iterations"); do
|
||
id="$($COMPOSE ps -q "$service" 2>/dev/null || true)"
|
||
if [ -n "$id" ]; then
|
||
status="$(docker inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)"
|
||
[ "$status" = "healthy" ] && return 0
|
||
fi
|
||
sleep 2
|
||
done
|
||
return 1
|
||
}
|
||
|
||
log_info "Baue Docker-Images (das kann beim ersten Mal einige Minuten dauern) …"
|
||
$COMPOSE build
|
||
|
||
log_info "Starte Datenbank …"
|
||
$COMPOSE up -d postgres
|
||
|
||
log_info "Warte auf Datenbank-Healthcheck …"
|
||
DB_READY=false
|
||
wait_healthy postgres 60 && DB_READY=true
|
||
[ "$DB_READY" = true ] || fail "Datenbank wurde nicht rechtzeitig healthy." "Prüfe die Logs: $COMPOSE logs postgres"
|
||
log_success "Datenbank ist bereit."
|
||
|
||
log_info "Führe Datenbank-Migrationen aus …"
|
||
$COMPOSE run --rm app npm run migrate || fail "Migration fehlgeschlagen." "Prüfe die Ausgabe oben. Die Datenbank wurde nicht verändert, wenn die Migration atomar fehlgeschlagen ist."
|
||
log_success "Migrationen angewendet."
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 13: seed
|
||
# ---------------------------------------------------------------------------
|
||
if [ "$SKIP_SEED" = false ]; then
|
||
DO_SEED="$(ask_yn "Vorhandene ANOUMA-Inhalte (Angebote, Seitentexte) jetzt einspielen?" "Y")"
|
||
if [ "$DO_SEED" = true ]; then
|
||
log_info "Spiele Seed-Daten ein …"
|
||
$COMPOSE run --rm app npm run seed || log_warn "Seed ist fehlgeschlagen — die Installation läuft trotzdem weiter, Inhalte können später im Admin-Bereich gepflegt werden."
|
||
fi
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 14–15: start app, healthcheck
|
||
# ---------------------------------------------------------------------------
|
||
log_info "Starte Anwendung …"
|
||
$COMPOSE up -d app
|
||
|
||
log_info "Warte auf Healthcheck der Anwendung …"
|
||
APP_READY=false
|
||
wait_healthy app 60 && APP_READY=true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# AUTO_UPDATE cron
|
||
# ---------------------------------------------------------------------------
|
||
if [ "$(env_get AUTO_UPDATE)" = "true" ] && command -v crontab >/dev/null 2>&1; then
|
||
CRON_MARKER="# anouma-auto-update"
|
||
if ! (crontab -l 2>/dev/null | grep -qF "$CRON_MARKER"); then
|
||
( crontab -l 2>/dev/null; echo "*/30 * * * * cd $(pwd) && ./update.sh --auto >> $(pwd)/update.log 2>&1 $CRON_MARKER" ) | crontab -
|
||
log_success "Cronjob für automatische Updates eingerichtet (alle 30 Minuten)."
|
||
else
|
||
log_info "Cronjob für automatische Updates ist bereits eingerichtet."
|
||
fi
|
||
fi
|
||
|
||
echo "$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" > .installed-version 2>/dev/null || true
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 16: result
|
||
# ---------------------------------------------------------------------------
|
||
echo ""
|
||
echo "ANOUMA installation complete"
|
||
echo ""
|
||
echo "Version:"
|
||
echo " $(cat .installed-version 2>/dev/null || echo unknown)"
|
||
echo ""
|
||
echo "Application:"
|
||
echo " $(env_get NEXT_PUBLIC_SERVER_URL)"
|
||
echo ""
|
||
echo "Docker:"
|
||
echo " ✓ Running"
|
||
echo ""
|
||
echo "Database:"
|
||
if [ "$DB_READY" = true ]; then echo " ✓ Healthy"; else echo " ✗ Not healthy — check: $COMPOSE logs postgres"; fi
|
||
echo ""
|
||
echo "Application healthcheck:"
|
||
if [ "$APP_READY" = true ]; then echo " ✓ Running"; else echo " ✗ Not healthy yet — check: $COMPOSE logs app"; fi
|
||
echo ""
|
||
echo "Nginx Proxy Manager:"
|
||
if [ "$NPM_DETECTED" = true ]; then echo " ✓ Detected ($DETECTED_NPM_NETWORK)"; else echo " - Not detected"; fi
|
||
echo ""
|
||
echo "Next steps:"
|
||
echo " 1. Visit ${NPM_DETECTED:+your configured domain, or }http://localhost:${APP_PORT:-3000}/admin to create the first admin account."
|
||
echo " 2. See DEPLOYMENT.md for reverse-proxy setup, backups, updates and troubleshooting."
|
||
echo ""
|