deploy.sh
bash
sha256:8e5bf0f59b0dbd014580dec546aafd5eac90ea0915244fda362261505ebafc8f
fix(#194): deploy.sh's ECR_REGISTRY now derives from ECR_IM…
Sonnet 5
minor
⚠ breaking
3 hours ago
| 1 | #!/usr/bin/env bash |
| 2 | # Zero-downtime blue-green deploy for MuseHub. |
| 3 | # |
| 4 | # Strategy: |
| 5 | # Two slots — blue (port 1337) and green (port 1338). |
| 6 | # The active slot serves traffic via nginx. The inactive slot is stopped. |
| 7 | # Deploy: |
| 8 | # 1. Pull the new image from ECR (old slot keeps serving). |
| 9 | # 2. Run migrations against the live DB (before swap — forward-compatible). |
| 10 | # 3. Start the inactive slot with the new image. |
| 11 | # 4. Health-check the new slot. |
| 12 | # 5. Flip nginx to the new slot (nginx -s reload — instant, zero downtime). |
| 13 | # 6. Stop the old slot. |
| 14 | # |
| 15 | # Called by deploy/push.sh via SSM — do not run directly in production. |
| 16 | # For manual use on the instance (ECR_IMAGE must match the account this |
| 17 | # instance lives in — Nonproduction for staging, Production for prod): |
| 18 | # ECR_IMAGE=<account-id>.dkr.ecr.us-east-1.amazonaws.com/musehub/musehub \ |
| 19 | # IMAGE_TAG=<tag> bash deploy/deploy.sh |
| 20 | # |
| 21 | # First-time setup: |
| 22 | # bash deploy/deploy.sh --init |
| 23 | # (Initialises .active-slot and /etc/nginx/musehub-active-port if missing) |
| 24 | |
| 25 | set -euo pipefail |
| 26 | |
| 27 | APP_DIR="/opt/musehub" |
| 28 | DEPLOY_LOG="/tmp/musehub-deploy.log" |
| 29 | |
| 30 | # Tee all output to a log file so push.sh can stream it live via a second SSM call. |
| 31 | exec > >(tee -a "$DEPLOY_LOG") 2>&1 |
| 32 | echo "" >> "$DEPLOY_LOG" |
| 33 | echo "=== deploy started at $(date -u '+%Y-%m-%dT%H:%M:%SZ') ===" >> "$DEPLOY_LOG" |
| 34 | SLOT_FILE="$APP_DIR/.active-slot" |
| 35 | NGINX_PORT_FILE="/etc/nginx/musehub-active-port" |
| 36 | # Default to staging's registry for manual on-instance invocation with no |
| 37 | # ECR_IMAGE set. push.sh always passes a fully-qualified, per-environment |
| 38 | # ECR_IMAGE (see its ECR_REGISTRY associative array) — this default only |
| 39 | # matters when someone runs this script by hand. |
| 40 | ECR_IMAGE="${ECR_IMAGE:-992382692655.dkr.ecr.us-east-1.amazonaws.com/musehub/musehub}" |
| 41 | IMAGE_TAG="${IMAGE_TAG:-latest}" |
| 42 | # Derived from ECR_IMAGE, never independently hardcoded — docker login must |
| 43 | # always authenticate against the same registry docker pull is about to use. |
| 44 | # musehub#194: a separately hardcoded ECR_REGISTRY here (always Nonproduction) |
| 45 | # silently drifted from ECR_IMAGE's actual registry once "prod" deploys |
| 46 | # started passing a real Production image, so a "prod" deploy authenticated |
| 47 | # to Nonproduction while trying to pull from Production. |
| 48 | ECR_REGISTRY="${ECR_IMAGE%%/*}" |
| 49 | MUSEHUB_ENV="${MUSEHUB_ENV:-staging}" |
| 50 | # Ceiling for the app/worker containers' memory cgroup. Unpacking a pushed |
| 51 | # mpack currently loads the whole payload + all decoded objects into memory |
| 52 | # at once (no streaming) — repos with large mpacks (~300MB+) can OOM at the |
| 53 | # default 2g. 3g leaves real headroom on this instance's 3.7G total without |
| 54 | # starving postgres. This is a stopgap, not a fix for the underlying |
| 55 | # non-streaming unpack path (see production-readiness follow-up). |
| 56 | APP_MEMORY_LIMIT="${APP_MEMORY_LIMIT:-3g}" |
| 57 | FULL_IMAGE="${ECR_IMAGE}:${IMAGE_TAG}" |
| 58 | REGION="us-east-1" |
| 59 | HEALTH_URL_BLUE="http://127.0.0.1:1337/healthz" |
| 60 | HEALTH_URL_GREEN="http://127.0.0.1:1338/healthz" |
| 61 | HEALTH_RETRIES=30 # × 2s = 60s max wait |
| 62 | |
| 63 | cd "$APP_DIR" |
| 64 | |
| 65 | # ── Helpers ─────────────────────────────────────────────────────────────────── |
| 66 | |
| 67 | log() { echo "[deploy] $*"; } |
| 68 | die() { echo "[deploy] ERROR: $*" >&2; exit 1; } |
| 69 | |
| 70 | health_check() { |
| 71 | local url="$1" |
| 72 | local slot="$2" |
| 73 | log "Health-checking $slot at $url ..." |
| 74 | for i in $(seq 1 "$HEALTH_RETRIES"); do |
| 75 | if curl -sf --max-time 3 "$url" > /dev/null 2>&1; then |
| 76 | log "$slot is healthy (attempt $i)" |
| 77 | return 0 |
| 78 | fi |
| 79 | sleep 2 |
| 80 | done |
| 81 | die "$slot failed health check after $((HEALTH_RETRIES * 2))s" |
| 82 | } |
| 83 | |
| 84 | nginx_point_to() { |
| 85 | local slot="$1" |
| 86 | sudo musehub-set-slot "$slot" |
| 87 | log "nginx now pointing to $slot" |
| 88 | } |
| 89 | |
| 90 | # Repair the active-port file if it contains a bare port number instead of |
| 91 | # a full nginx upstream directive. Called once at startup so a botched |
| 92 | # manual intervention cannot be the root cause of a new deploy failing. |
| 93 | sanitize_nginx_port_file() { |
| 94 | [ -f "$NGINX_PORT_FILE" ] || return 0 |
| 95 | local content |
| 96 | content=$(cat "$NGINX_PORT_FILE") |
| 97 | # Already correct — nothing to do |
| 98 | if echo "$content" | grep -qE '^server 127\.0\.0\.1:[0-9]+;$'; then |
| 99 | return 0 |
| 100 | fi |
| 101 | # Derive correct slot from .active-slot file, or fall back to blue |
| 102 | local slot |
| 103 | slot=$(cat "$SLOT_FILE" 2>/dev/null || echo "blue") |
| 104 | if [ "$slot" != "blue" ] && [ "$slot" != "green" ]; then |
| 105 | slot="blue" |
| 106 | fi |
| 107 | log "WARNING: $NGINX_PORT_FILE has unexpected content — correcting via musehub-set-slot $slot" |
| 108 | sudo musehub-set-slot "$slot" |
| 109 | log "Sanitized active-port file; nginx reloaded." |
| 110 | } |
| 111 | |
| 112 | # ── Init mode ───────────────────────────────────────────────────────────────── |
| 113 | |
| 114 | if [ "${1:-}" = "--init" ]; then |
| 115 | log "Init: installing musehub-set-slot and pointing nginx to blue" |
| 116 | sudo cp "$APP_DIR/deploy/set-active-slot.sh" /usr/local/bin/musehub-set-slot |
| 117 | sudo chmod +x /usr/local/bin/musehub-set-slot |
| 118 | sudo musehub-set-slot blue |
| 119 | log "Done. Run 'bash deploy/deploy.sh' (with ECR_IMAGE and IMAGE_TAG set) to deploy." |
| 120 | exit 0 |
| 121 | fi |
| 122 | |
| 123 | # ── Validate required env vars ──────────────────────────────────────────────── |
| 124 | |
| 125 | [ -n "${ECR_IMAGE:-}" ] || die "ECR_IMAGE is not set." |
| 126 | [ -n "${IMAGE_TAG:-}" ] || die "IMAGE_TAG is not set." |
| 127 | |
| 128 | # ── Read active slot ────────────────────────────────────────────────────────── |
| 129 | |
| 130 | if [ ! -f "$SLOT_FILE" ]; then |
| 131 | die ".active-slot not found. Run: bash deploy/deploy.sh --init" |
| 132 | fi |
| 133 | |
| 134 | ACTIVE_SLOT=$(cat "$SLOT_FILE") |
| 135 | if [ "$ACTIVE_SLOT" = "blue" ]; then |
| 136 | NEW_SLOT="green" |
| 137 | NEW_PORT=1338 |
| 138 | OLD_CONTAINER="musehub-blue" |
| 139 | NEW_CONTAINER="musehub-green" |
| 140 | HEALTH_URL="$HEALTH_URL_GREEN" |
| 141 | else |
| 142 | NEW_SLOT="blue" |
| 143 | NEW_PORT=1337 |
| 144 | OLD_CONTAINER="musehub-green" |
| 145 | NEW_CONTAINER="musehub-blue" |
| 146 | HEALTH_URL="$HEALTH_URL_BLUE" |
| 147 | fi |
| 148 | |
| 149 | log "Image: $FULL_IMAGE" |
| 150 | log "Active slot: $ACTIVE_SLOT → deploying to: $NEW_SLOT (port $NEW_PORT)" |
| 151 | |
| 152 | # Guard: ensure the nginx upstream file is well-formed before we touch anything. |
| 153 | sanitize_nginx_port_file |
| 154 | |
| 155 | # ── Step 0: Apply nginx config if updated ──────────────────────────────────── |
| 156 | # Determine the domain from the current installed config, re-substitute, and |
| 157 | # reload nginx if the content changed. Safe to run on every deploy. |
| 158 | |
| 159 | NGINX_CONF_SRC="$APP_DIR/deploy/nginx-cf.conf" |
| 160 | NGINX_CONF_DEST="/etc/nginx/sites-available/musehub-staging" |
| 161 | NGINX_CONF_DEST_PROD="/etc/nginx/sites-available/musehub" |
| 162 | |
| 163 | if [ -f "$NGINX_CONF_SRC" ]; then |
| 164 | # Detect which installed config exists (staging vs prod) |
| 165 | if [ -f "$NGINX_CONF_DEST" ]; then |
| 166 | NGINX_CONF_INSTALLED="$NGINX_CONF_DEST" |
| 167 | elif [ -f "$NGINX_CONF_DEST_PROD" ]; then |
| 168 | NGINX_CONF_INSTALLED="$NGINX_CONF_DEST_PROD" |
| 169 | else |
| 170 | NGINX_CONF_INSTALLED="" |
| 171 | fi |
| 172 | |
| 173 | if [ -n "$NGINX_CONF_INSTALLED" ]; then |
| 174 | # Extract domain from the installed config (first server_name line) |
| 175 | DOMAIN=$(grep -m1 'server_name' "$NGINX_CONF_INSTALLED" | awk '{print $2}' | tr -d ';') |
| 176 | if [ -n "$DOMAIN" ]; then |
| 177 | NEW_CONF=$(sed "s/DOMAIN_PLACEHOLDER/$DOMAIN/g" "$NGINX_CONF_SRC") |
| 178 | CURRENT_CONF=$(cat "$NGINX_CONF_INSTALLED") |
| 179 | if [ "$NEW_CONF" != "$CURRENT_CONF" ]; then |
| 180 | log "[0/6] nginx config changed — applying update for $DOMAIN..." |
| 181 | echo "$NEW_CONF" | sudo tee "$NGINX_CONF_INSTALLED" > /dev/null |
| 182 | if sudo nginx -t 2>&1; then |
| 183 | sudo nginx -s reload |
| 184 | log "nginx config updated and reloaded." |
| 185 | else |
| 186 | log "WARNING: new nginx config failed validation — reverting." |
| 187 | echo "$CURRENT_CONF" | sudo tee "$NGINX_CONF_INSTALLED" > /dev/null |
| 188 | fi |
| 189 | else |
| 190 | log "[0/6] nginx config unchanged — skipping reload." |
| 191 | fi |
| 192 | fi |
| 193 | fi |
| 194 | fi |
| 195 | |
| 196 | # ── Step 1: Login to ECR and pull new image ─────────────────────────────────── |
| 197 | # Retries the full login+pull cycle (not just the pull) since a stale/expired |
| 198 | # token is the failure mode seen in practice ("Your authorization token has |
| 199 | # expired" immediately after a successful `docker login`) -- re-fetching a |
| 200 | # fresh token from scratch on each attempt is the fix, not just retrying the |
| 201 | # pull with the same (possibly bad) token. |
| 202 | |
| 203 | log "[1/6] Pulling image from ECR..." |
| 204 | PULL_ATTEMPTS=3 |
| 205 | for attempt in $(seq 1 "$PULL_ATTEMPTS"); do |
| 206 | # `docker logout` before each attempt: observed in practice that retrying |
| 207 | # login+pull within the *same* invocation can keep hitting the same stale |
| 208 | # cached credential state, while a `docker logout` first (clearing |
| 209 | # ~/.docker/config.json's entry for this registry) reliably unblocks it -- |
| 210 | # equivalent to what a completely separate, later invocation was doing by |
| 211 | # accident. |
| 212 | sudo docker logout "$ECR_REGISTRY" >/dev/null 2>&1 || true |
| 213 | if aws ecr get-login-password --region "$REGION" | \ |
| 214 | sudo docker login --username AWS --password-stdin "$ECR_REGISTRY" \ |
| 215 | && sudo docker pull "$FULL_IMAGE"; then |
| 216 | break |
| 217 | fi |
| 218 | if [ "$attempt" -eq "$PULL_ATTEMPTS" ]; then |
| 219 | die "ECR login/pull failed after $PULL_ATTEMPTS attempts." |
| 220 | fi |
| 221 | log "ECR login/pull failed (attempt $attempt/$PULL_ATTEMPTS) — retrying in 5s..." |
| 222 | sleep 5 |
| 223 | done |
| 224 | log "Pull complete." |
| 225 | |
| 226 | # ── Step 2: Run migrations against the live DB ──────────────────────────────── |
| 227 | |
| 228 | log "[2/6] Running migrations..." |
| 229 | |
| 230 | _alembic() { |
| 231 | sudo docker run --rm \ |
| 232 | --network musehub_musehub-internal \ |
| 233 | --env-file "$APP_DIR/.env" \ |
| 234 | -e SKIP_MIGRATIONS=0 \ |
| 235 | "$FULL_IMAGE" "$@" |
| 236 | } |
| 237 | |
| 238 | # If upgrade head fails (e.g. stale revision ID from a migration history reset), |
| 239 | # stamp to the current head to re-anchor Alembic's tracking, then retry. |
| 240 | # The retry is a no-op when the schema already matches head. |
| 241 | if ! _alembic alembic upgrade head; then |
| 242 | log "upgrade head failed — re-anchoring Alembic revision to head and retrying..." |
| 243 | _alembic alembic stamp --purge head |
| 244 | _alembic alembic upgrade head |
| 245 | fi |
| 246 | log "Migrations complete." |
| 247 | |
| 248 | # Schema parity gate — hard fail. Uses the same benign-diff filter as the S2 |
| 249 | # test (alembic_version table, semantically-equivalent server_default variants, |
| 250 | # column comments) so spurious false positives never block a deploy. |
| 251 | _alembic python -m musehub.db.schema_gate \ |
| 252 | || die "Schema gate failed — ORM drift detected. Write a migration (alembic revision --autogenerate) before deploying." |
| 253 | |
| 254 | # ── Step 3: Start the new slot ──────────────────────────────────────────────── |
| 255 | |
| 256 | log "[3/6] Starting $NEW_SLOT on port $NEW_PORT..." |
| 257 | |
| 258 | # Remove if a failed previous deploy left it around |
| 259 | sudo docker rm -f "$NEW_CONTAINER" 2>/dev/null || true |
| 260 | |
| 261 | sudo docker run -d \ |
| 262 | --name "$NEW_CONTAINER" \ |
| 263 | --network musehub_musehub-internal \ |
| 264 | --env-file "$APP_DIR/.env" \ |
| 265 | -e SKIP_MIGRATIONS=1 \ |
| 266 | -e RELEASE_VERSION="${IMAGE_TAG}" \ |
| 267 | -v musehub_data:/data \ |
| 268 | -p "127.0.0.1:${NEW_PORT}:1337" \ |
| 269 | --restart unless-stopped \ |
| 270 | --memory "$APP_MEMORY_LIMIT" \ |
| 271 | --log-driver awslogs \ |
| 272 | --log-opt awslogs-region=us-east-1 \ |
| 273 | --log-opt awslogs-group=/musehub/${MUSEHUB_ENV} \ |
| 274 | --log-opt awslogs-stream="$NEW_CONTAINER" \ |
| 275 | --log-opt awslogs-create-group=true \ |
| 276 | "$FULL_IMAGE" |
| 277 | |
| 278 | # ── Step 4: Health-check the new slot ──────────────────────────────────────── |
| 279 | |
| 280 | health_check "$HEALTH_URL" "$NEW_SLOT" |
| 281 | |
| 282 | # ── Step 5: Flip nginx to the new slot (instant, zero downtime) ─────────────── |
| 283 | |
| 284 | log "[5/6] Switching nginx to $NEW_SLOT (port $NEW_PORT)..." |
| 285 | nginx_point_to "$NEW_SLOT" |
| 286 | |
| 287 | # ── Step 6: Stop the old slot ──────────────────────────────────────────────── |
| 288 | |
| 289 | log "[6/6] Stopping old slot ($ACTIVE_SLOT)..." |
| 290 | # `docker stop` sends SIGTERM and waits (--time) before SIGKILL, giving the |
| 291 | # app's lifespan shutdown handler (closes the DB pool, stops the Playwright |
| 292 | # browser) a chance to actually run, and letting any in-flight requests that |
| 293 | # were accepted just before the nginx flip finish rather than being dropped. |
| 294 | # `docker rm -f` (the previous behavior) sends SIGKILL immediately and skips |
| 295 | # all of that — the graceful-shutdown code existed but was never triggered. |
| 296 | sudo docker stop --time 15 "$OLD_CONTAINER" 2>/dev/null || true |
| 297 | sudo docker rm -f "$OLD_CONTAINER" 2>/dev/null || true |
| 298 | |
| 299 | # ── Step 7: Restart the background worker ──────────────────────────────────── |
| 300 | |
| 301 | log "[7/7] Restarting background worker..." |
| 302 | sudo docker stop --time 15 musehub-worker 2>/dev/null || true |
| 303 | sudo docker rm -f musehub-worker 2>/dev/null || true |
| 304 | sudo docker run -d \ |
| 305 | --name musehub-worker \ |
| 306 | --network musehub_musehub-internal \ |
| 307 | --env-file "$APP_DIR/.env" \ |
| 308 | -e SKIP_MIGRATIONS=1 \ |
| 309 | -e RELEASE_VERSION="${IMAGE_TAG}" \ |
| 310 | -v musehub_data:/data \ |
| 311 | --restart unless-stopped \ |
| 312 | --no-healthcheck \ |
| 313 | --memory "$APP_MEMORY_LIMIT" \ |
| 314 | --log-driver awslogs \ |
| 315 | --log-opt awslogs-region=us-east-1 \ |
| 316 | --log-opt awslogs-group=/musehub/${MUSEHUB_ENV} \ |
| 317 | --log-opt awslogs-stream=musehub-worker \ |
| 318 | --log-opt awslogs-create-group=true \ |
| 319 | "$FULL_IMAGE" python -m musehub.worker |
| 320 | log "Worker started." |
| 321 | |
| 322 | # ── Step 8: Prune old images (keep last 3) ─────────────────────────────────── |
| 323 | |
| 324 | log "[8/8] Pruning old images (keeping last 3)..." |
| 325 | KEEP_IMAGES=3 |
| 326 | OLD_IDS=$(sudo docker images "$ECR_IMAGE" --format "{{.ID}}" \ |
| 327 | | awk '!seen[$0]++' \ |
| 328 | | tail -n +$((KEEP_IMAGES + 1))) |
| 329 | if [ -n "$OLD_IDS" ]; then |
| 330 | echo "$OLD_IDS" | xargs sudo docker rmi -f 2>/dev/null || true |
| 331 | log "Image prune complete." |
| 332 | else |
| 333 | log "No old images to prune." |
| 334 | fi |
| 335 | |
| 336 | log "" |
| 337 | log "Deploy complete. Active slot: $NEW_SLOT (port $NEW_PORT)" |
| 338 | log "Image: $FULL_IMAGE" |
File History
1 commit
sha256:8e5bf0f59b0dbd014580dec546aafd5eac90ea0915244fda362261505ebafc8f
fix(#194): deploy.sh's ECR_REGISTRY now derives from ECR_IM…
Sonnet 5
minor
⚠
3 hours ago