#!/usr/bin/env bash # ANOUMA — server-side deployment script. # # This is NOT a manual update tool. It is invoked exclusively by Gitea # Actions over SSH as the restricted `anouma-deploy` user (see setup.sh, # which installs a forced-command SSH key that can only ever run this # script). The production server never builds an image — it only ever # pulls an already-built, already-tested tag from the registry. # # deploy.sh deploy vX.Y.Z # # Runs from /opt/anouma (installed there by setup.sh). Safe by design: # never deletes volumes, never force-deploys an untagged commit, always # backs up the database first, and rolls the *code* back automatically if # the post-deploy 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; } # --------------------------------------------------------------------------- # When invoked through the restricted SSH key (see setup.sh), OpenSSH's # forced "command=" ignores whatever the SSH client asked for and runs this # script with NO arguments — the client's actual request arrives in # $SSH_ORIGINAL_COMMAND instead. Re-split it into "$@" so the rest of this # script doesn't need to care which path it came from. This is intentionally # unquoted (word-splitting, not `eval`) — no shell metacharacter in # SSH_ORIGINAL_COMMAND is ever interpreted as code, it just becomes literal # argument text, which the strict version regex below then validates. # --------------------------------------------------------------------------- if [ $# -eq 0 ] && [ -n "${SSH_ORIGINAL_COMMAND:-}" ]; then # shellcheck disable=SC2086 set -- $SSH_ORIGINAL_COMMAND fi ACTION="${1:-}" VERSION="${2:-}" [ -f .env ] || fail ".env not found — run setup.sh on this server first." COMPOSE="docker compose" docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose" # --------------------------------------------------------------------------- # Deployment lock — never run two deployments concurrently. # --------------------------------------------------------------------------- LOCK_FILE="$(pwd)/.deploy.lock" if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then log_warn "Deployment already running." exit 0 fi echo $$ > "$LOCK_FILE" trap 'rm -f "$LOCK_FILE"' EXIT # --------------------------------------------------------------------------- # .env helpers — same idempotent read/write approach as setup.sh. # --------------------------------------------------------------------------- env_get() { grep -E "^$1=" .env 2>/dev/null | tail -n1 | cut -d'=' -f2- || true; } env_set() { 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 } 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 } backup_database() { mkdir -p backups local file="backups/database-$(date +%Y-%m-%d-%H%M).sql.gz" if $COMPOSE ps -q postgres >/dev/null 2>&1 && [ -n "$($COMPOSE ps -q postgres)" ]; then log_info "Backing up database → $file" $COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$file" log_success "Backup created ($(du -h "$file" | cut -f1))." else log_warn "Postgres is not running yet — skipping backup (first-ever deploy?)." fi local retention="${BACKUP_RETENTION_DAYS:-14}" find backups -name 'database-*.sql.gz' -mtime "+${retention}" -not -name "$(basename "$file")" -delete 2>/dev/null || true } registry_login() { if [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then echo "$REGISTRY_PASSWORD" | docker login "${REGISTRY:-git.maro.run}" -u "$REGISTRY_USERNAME" --password-stdin >/dev/null 2>&1 \ && log_success "Registry login OK." \ || fail "Registry login failed — check REGISTRY_USERNAME/REGISTRY_PASSWORD in .env." fi } # Sets ANOUMA_IMAGE in .env to the given release tag, then re-sources .env so # the rest of this script (and `docker compose`, via env_file) picks it up. set_image_version() { local version="$1" env_set ANOUMA_IMAGE "${REGISTRY:-git.maro.run}/${REGISTRY_REPO:-maro/anouma}:${version}" set -a; source .env; set +a } # One full deploy attempt for a given version tag: pull → migrate → start → # healthcheck. Returns non-zero on any failure without partially cleaning up # — the caller decides whether to retry with the previous version. deploy_version() { local version="$1" set_image_version "$version" registry_login log_info "Pulling ${ANOUMA_IMAGE} …" $COMPOSE pull app || return 1 log_info "Running database migrations …" $COMPOSE run --rm app npm run migrate || return 1 log_info "Starting containers …" $COMPOSE up -d || return 1 log_info "Waiting for healthcheck …" wait_healthy app 60 } do_deploy() { local version="$1" [[ "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Invalid version '${version}' (expected vX.Y.Z)." set -a; source .env; set +a local previous_version="" local previous_image; previous_image="$(env_get ANOUMA_IMAGE)" if [[ "$previous_image" =~ :(v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then previous_version="${BASH_REMATCH[1]}" fi log_info "Deploying ${version} (current: ${previous_version:-none}) …" backup_database if deploy_version "$version"; then log_success "${version} is live." echo "$version" > .current-version exit 0 fi log_error "${version} FAILED post-deploy healthcheck." if [ -z "$previous_version" ]; then fail "No previous version on record — nothing to roll back to. Check: $COMPOSE logs app" fi log_warn "Rolling back to ${previous_version} …" if deploy_version "$previous_version"; then log_warn "Rollback to ${previous_version} succeeded — production is back on the previous version." log_warn "Database migrations from the failed deploy were NOT reverted (see DEPLOYMENT.md) — restore the backup above manually if ${version} shipped an incompatible migration." echo "$previous_version" > .current-version exit 1 fi fail "Rollback to ${previous_version} ALSO failed — manual intervention required: $COMPOSE logs app" } case "$ACTION" in deploy) [ -n "$VERSION" ] || fail "Usage: deploy.sh deploy vX.Y.Z" do_deploy "$VERSION" ;; *) echo "Usage: deploy.sh deploy vX.Y.Z" >&2 exit 1 ;; esac