hm_ai_qc_report_tool/deploy.sh
nickviljoen aacefbd7df deploy.sh: handle first-deploy and --force re-deploys
The previous version short-circuited with 'nothing to do' when local
HEAD already matched the target — which is wrong on the first deploy
(no container yet) and inconvenient when re-running after a manual
fix. Now:
  * Auto-proceeds when no container is running for the web service.
  * Accepts --force to rebuild + restart at the same revision.
  * Skips the empty changelog section when CURRENT == TARGET.
2026-05-09 16:35:36 +02:00

209 lines
6 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/bin/bash
# HM AI QC deploy script (Docker Compose).
#
# Usage:
# deploy.sh dev Deploy origin/develop HEAD to this server
# deploy.sh prod <tag> Deploy a specific tag to this server
# deploy.sh dev --dry-run Show what would change, make no changes
# deploy.sh prod <tag> --dry-run
#
# Runs on the target server (optical-dev / optical-prod), not your laptop.
# Saves a rollback checkpoint to .last_deploy_rollback before changing
# anything, and auto-rolls back if the post-deploy /health probe fails.
#
# Differences from the AI QC sibling script (intentional):
# * Docker Compose, not systemd. `docker compose up -d` replaces
# systemctl restart; `docker compose build` replaces pip install.
# * `flask db upgrade` runs as a one-shot container before bringing up
# the web service, so schema changes apply atomically with the deploy.
# * No "delete frontend / build frontend / copy to /var/www/html" steps
# from the IT spec — HM QC ships Flask templates, not an SPA bundle.
set -euo pipefail
APP_DIR=${APP_DIR:-/opt/hm-aiqc}
HEALTH_URL=${HEALTH_URL:-http://127.0.0.1:5050/health}
ROLLBACK_FILE="$APP_DIR/.last_deploy_rollback"
MODE=${1:-}
shift || true
DRY_RUN=false
FORCE=false
TARGET_TAG=""
parse_flags() {
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--force) FORCE=true ;;
esac
done
}
case "$MODE" in
dev)
parse_flags "$@"
;;
prod)
TARGET_TAG=${1:-}
shift || true
parse_flags "$@"
if [[ -z "$TARGET_TAG" ]]; then
echo "Usage: $0 prod <tag> [--dry-run] [--force]"
exit 1
fi
;;
""|-h|--help)
cat <<EOF
Usage:
$(basename "$0") dev [--dry-run] [--force]
$(basename "$0") prod <tag> [--dry-run] [--force]
--dry-run Show what would change, make no changes.
--force Re-build + re-up even if local HEAD already matches the target.
Implied automatically on first deploy (no running container).
Run on the target server. Requires permission to talk to docker.
EOF
exit 0
;;
*)
echo "Unknown mode: $MODE"
echo "Try: $(basename "$0") --help"
exit 1
;;
esac
cd "$APP_DIR"
if [[ ! -d .git ]]; then
echo "ERROR: $APP_DIR is not a git repo"
exit 1
fi
if [[ ! -f .env ]]; then
echo "ERROR: $APP_DIR/.env not found. Copy from deploy/.env.${MODE}.example and fill in."
exit 1
fi
CURRENT_REV=$(git rev-parse HEAD)
CURRENT_SHORT=$(git rev-parse --short HEAD)
echo "============================================"
echo " HM AI QC deploy ($MODE)"
echo "============================================"
echo "Server: $(hostname)"
echo "Current: $CURRENT_SHORT $(git log -1 --format='%s' HEAD)"
echo ""
echo "Fetching latest refs..."
git fetch --tags --prune --quiet
if [[ "$MODE" == "dev" ]]; then
TARGET_REF="origin/develop"
else
if ! git rev-parse --verify --quiet "refs/tags/$TARGET_TAG^{commit}" > /dev/null; then
echo "ERROR: Tag '$TARGET_TAG' not found after fetch"
exit 1
fi
TARGET_REF="refs/tags/$TARGET_TAG"
fi
TARGET_REV=$(git rev-parse "$TARGET_REF")
TARGET_SHORT=$(git rev-parse --short "$TARGET_REF")
CONTAINER_RUNNING=false
if docker compose ps --status running --quiet web 2>/dev/null | grep -q .; then
CONTAINER_RUNNING=true
fi
if [[ "$CURRENT_REV" == "$TARGET_REV" ]]; then
if [[ "$CONTAINER_RUNNING" == "false" ]]; then
echo "Already at $TARGET_SHORT, but no running container — proceeding to build/up."
FORCE=true
elif [[ "$FORCE" == "true" ]]; then
echo "Already at $TARGET_SHORT — --force passed, re-building and restarting."
else
echo "Already at $TARGET_SHORT — nothing to do. (Pass --force to rebuild anyway.)"
exit 0
fi
fi
echo "Target: $TARGET_SHORT $(git log -1 --format='%s' "$TARGET_REF")"
echo ""
if [[ "$CURRENT_REV" != "$TARGET_REV" ]]; then
echo "Commits to apply:"
git log --oneline "$CURRENT_REV..$TARGET_REV" | head -20
CHANGE_COUNT=$(git log --oneline "$CURRENT_REV..$TARGET_REV" | wc -l | tr -d ' ')
if [[ $CHANGE_COUNT -gt 20 ]]; then
echo " ... and $((CHANGE_COUNT - 20)) more"
fi
echo ""
if git diff --name-only "$CURRENT_REV" "$TARGET_REV" | grep -qE "(^|/)migrations/versions/"; then
echo "Note: Alembic migrations changed — flask db upgrade will run."
echo ""
fi
fi
if [[ "$DRY_RUN" == "true" ]]; then
echo "Dry run — no changes made."
exit 0
fi
read -r -p "Proceed with deploy? (y/N): " confirm
if [[ ! $confirm =~ ^[Yy]$ ]]; then
echo "Cancelled."
exit 0
fi
echo "$CURRENT_REV" > "$ROLLBACK_FILE"
echo "Applying changes..."
git reset --hard "$TARGET_REV"
echo "Building images..."
docker compose build
echo "Starting services (entrypoint runs flask db upgrade first)..."
docker compose up -d
# Poll $HEALTH_URL every 2s until it answers 2xx, or timeout.
# 60s window allows for migration time on first boot of a major release.
wait_for_health() {
local max_attempts=30 # 30 × 2s = 60s window
for ((i=1; i<=max_attempts; i++)); do
sleep 2
if curl -sf -o /dev/null "$HEALTH_URL"; then
echo " healthy after ${i}x2s"
return 0
fi
done
return 1
}
echo "Smoke testing $HEALTH_URL..."
if wait_for_health; then
NEW_SHORT=$(git rev-parse --short HEAD)
echo ""
echo "Deploy OK. Now at $NEW_SHORT."
echo "Rollback target saved: $CURRENT_SHORT (run deploy/rollback.sh last to revert)"
exit 0
fi
echo ""
echo "Smoke test failed after 60s — rolling back to $CURRENT_SHORT..."
git reset --hard "$CURRENT_REV"
docker compose build
docker compose up -d
if wait_for_health; then
echo "Rolled back successfully. Service healthy at $CURRENT_SHORT."
echo "Investigate: docker compose logs --tail=200 web"
exit 1
fi
echo "ROLLBACK ALSO FAILED. Service is in a broken state."
echo "docker compose ps"
echo "docker compose logs --tail=200 web"
exit 2