#!/usr/bin/env bash # ANOUMA — safe, release-based update. # # ./update.sh interactive update to the latest release tag # ./update.sh --auto used by the AUTO_UPDATE cron job — silently # does nothing if already on the latest release # ./update.sh vX.Y.Z update to a specific tag # # Never deletes volumes. Never force-deploys an untagged commit from main — # only real release tags (vX.Y.Z) are deployed. Backs up the database before # touching anything, and rolls the *code* back (not the database schema — # migrations should be forward-compatible, see DEPLOYMENT.md) if the # post-update healthcheck fails. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" 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"; exit 1; } command -v git >/dev/null 2>&1 || fail "Git wird für release-basierte Updates benötigt." command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden." COMPOSE="docker compose"; docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose" AUTO_MODE=false TARGET_VERSION="" for arg in "$@"; do case "$arg" in --auto) AUTO_MODE=true ;; v*) TARGET_VERSION="$arg" ;; esac done # --------------------------------------------------------------------------- # Update lock — prevents two updates (e.g. a manual one and the cron job) # from running at the same time. # --------------------------------------------------------------------------- LOCK_FILE="$(pwd)/.update.lock" if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then log_warn "Update already running." exit 0 fi echo $$ > "$LOCK_FILE" trap 'rm -f "$LOCK_FILE"' EXIT [ -f .env ] || fail ".env nicht gefunden — bitte zuerst ./setup.sh ausführen." [ -d .git ] || fail "Kein Git-Repository — Updates funktionieren nur in einem Checkout von https://git.maro.run/maro/anouma.git." if [ -n "$(git status --porcelain)" ]; then fail "Das Arbeitsverzeichnis hat uncommittete Änderungen — Update abgebrochen, um nichts zu überschreiben." fi CURRENT_REF="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)" log_info "Aktuelle Version: $CURRENT_REF" log_info "Prüfe auf neue Releases …" git fetch --tags --quiet origin if [ -n "$TARGET_VERSION" ]; then NEW_VERSION="$TARGET_VERSION" else # Highest vX.Y.Z tag, sorted as real version numbers (not alphabetically). NEW_VERSION="$(git tag -l 'v*' | sort -t. -k1.2,1n -k2,2n -k3,3n | tail -n1)" fi if [ -z "$NEW_VERSION" ]; then log_info "Keine Release-Tags im Repository gefunden — nichts zu deployen (main wird bewusst nicht automatisch deployt)." exit 0 fi if [ "$NEW_VERSION" = "$CURRENT_REF" ]; then log_info "Bereits auf dem neuesten Release ($CURRENT_REF)." exit 0 fi if [ "$AUTO_MODE" = true ]; then log_info "Neues Release gefunden: $NEW_VERSION (aktuell: $CURRENT_REF) — automatisches Update wird gestartet." fi log_info "Update: $CURRENT_REF → $NEW_VERSION" # --------------------------------------------------------------------------- # Backup # --------------------------------------------------------------------------- mkdir -p backups BACKUP_FILE="backups/database-$(date +%Y-%m-%d-%H%M).sql.gz" log_info "Erstelle Datenbank-Backup: $BACKUP_FILE" set -a; source .env; set +a if $COMPOSE ps -q postgres >/dev/null 2>&1 && [ -n "$($COMPOSE ps -q postgres)" ]; then $COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$BACKUP_FILE" log_success "Backup erstellt ($(du -h "$BACKUP_FILE" | cut -f1))." else log_warn "Datenbank-Container läuft nicht — Backup übersprungen." fi # Retention: delete backups older than BACKUP_RETENTION_DAYS, but never the # one we just created. RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}" find backups -name 'database-*.sql.gz' -mtime "+${RETENTION_DAYS}" -not -name "$(basename "$BACKUP_FILE")" -delete 2>/dev/null || true # --------------------------------------------------------------------------- # Deploy # --------------------------------------------------------------------------- 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 } deploy_ref() { local ref="$1" git checkout --quiet "$ref" $COMPOSE build $COMPOSE run --rm app npm run migrate $COMPOSE up -d } log_info "Checke $NEW_VERSION aus und baue neu …" if deploy_ref "$NEW_VERSION" && wait_healthy app 60; then log_success "Update auf $NEW_VERSION erfolgreich." echo "$NEW_VERSION" > .installed-version exit 0 fi log_error "Healthcheck nach Update auf $NEW_VERSION fehlgeschlagen — rolle Code auf $CURRENT_REF zurück." log_warn "Datenbank-Migrationen werden dabei NICHT rückgängig gemacht (siehe DEPLOYMENT.md) — falls $NEW_VERSION eine nicht abwärtskompatible Migration enthielt, stelle das Backup manuell wieder her: $BACKUP_FILE" if deploy_ref "$CURRENT_REF" && wait_healthy app 60; then log_warn "Rollback auf $CURRENT_REF erfolgreich — die Anwendung läuft wieder auf der vorherigen Version." exit 1 fi fail "Rollback ebenfalls fehlgeschlagen — bitte manuell prüfen: $COMPOSE logs app"