- deploy.sh dev|prod with --dry-run, auto-rollback if /health fails
within 60s; checkpoint saved to .last_deploy_rollback before reset
- deploy/rollback.sh last|<sha> with the same Docker compose dance
- deploy/health-check.sh — curl wrapper for monitoring/oncall
- deploy/apache-{dev,prod}.conf — Location blocks proxying /hm-aiqc/
to gunicorn on 127.0.0.1:5050 with X-Script-Name set so wsgi.py's
ReverseProxied middleware emits prefixed URLs
- deploy/.env.{dev,prod}.example — starter envs with Azure SSO config
184 lines
5.2 KiB
Bash
Executable file
184 lines
5.2 KiB
Bash
Executable file
#!/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
|
||
TARGET_TAG=""
|
||
|
||
case "$MODE" in
|
||
dev)
|
||
for arg in "$@"; do
|
||
[[ "$arg" == "--dry-run" ]] && DRY_RUN=true
|
||
done
|
||
;;
|
||
prod)
|
||
TARGET_TAG=${1:-}
|
||
shift || true
|
||
for arg in "$@"; do
|
||
[[ "$arg" == "--dry-run" ]] && DRY_RUN=true
|
||
done
|
||
if [[ -z "$TARGET_TAG" ]]; then
|
||
echo "Usage: $0 prod <tag> [--dry-run]"
|
||
exit 1
|
||
fi
|
||
;;
|
||
""|-h|--help)
|
||
cat <<EOF
|
||
Usage:
|
||
$(basename "$0") dev [--dry-run] Deploy latest develop to this server
|
||
$(basename "$0") prod <tag> [--dry-run] Deploy a specific tag to this server
|
||
|
||
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")
|
||
|
||
if [[ "$CURRENT_REV" == "$TARGET_REV" ]]; then
|
||
echo "Already at $TARGET_SHORT — nothing to do."
|
||
exit 0
|
||
fi
|
||
|
||
echo "Target: $TARGET_SHORT $(git log -1 --format='%s' "$TARGET_REF")"
|
||
echo ""
|
||
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
|
||
|
||
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
|