#!/bin/bash # HM AI QC deploy script (Docker Compose). # # Usage: # deploy.sh dev Deploy origin/develop HEAD to this server # deploy.sh prod Deploy a specific tag to this server # deploy.sh dev --dry-run Show what would change, make no changes # deploy.sh prod --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 [--dry-run] [--force]" exit 1 fi ;; ""|-h|--help) cat < [--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