Production no longer builds from source or self-updates via cron/git pull:
setup.sh now only provisions the server once (Docker, /opt/anouma, a
restricted `anouma-deploy` SSH user whose key can only ever run
deploy.sh, generated secrets). All future deployments run through
.gitea/workflows/ci.yml (lint/typecheck/test/build on every push) and
release.yml (on a vX.Y.Z tag: build the image, push it to the Gitea
registry, then SSH-trigger deploy.sh on the server), which pulls,
migrates, restarts, healthchecks, backs up the database first, and
automatically rolls back the code on a failed healthcheck.
docker-compose.yml's app service now runs a registry image
(${ANOUMA_IMAGE}) instead of building locally.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
180 lines
7.1 KiB
Bash
Executable File
180 lines
7.1 KiB
Bash
Executable File
#!/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
|