Files
anouma/deploy.sh
T
maroandClaude Sonnet 5 8d60d48bd0
CI / test (push) Successful in 3m14s
Fix .env writer: quote values so shell metacharacters don't break source
deploy.sh sources .env directly as bash (`set -a; source .env`), but
setup.sh wrote raw unquoted values. The default SMTP_FROM ("ANOUMA
<no-reply@...>") contains `<`/`>`, which bash parses as redirections,
failing with "syntax error near unexpected token `newline'" on every
deploy. Both scripts' env_set now write double-quoted values with
backslashes/quotes escaped — valid for both `source` and Docker
Compose's env_file parsing (which strips matching quotes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 02:12:24 +02:00

186 lines
7.3 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; }
# Quoted the same way as setup.sh's env_set — this file is `source`d as a
# bash script below, so an unquoted value with shell metacharacters would
# break parsing.
env_set() {
local key="$1" value="$2" escaped quoted
escaped="${value//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
quoted="\"${escaped}\""
if grep -qE "^$key=" .env; then
local tmp; tmp="$(mktemp)"
awk -v k="$key" -v v="$quoted" 'BEGIN{FS=OFS="="} $1==k{$0=k "=" v} {print}' .env > "$tmp" && mv "$tmp" .env
else
printf '%s=%s\n' "$key" "$quoted" >> .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