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.
209 lines
6 KiB
Bash
Executable file
209 lines
6 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
|
||
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
|