gabriel / musehub public
push.sh bash
309 lines 12.8 KB
Raw
sha256:915b1f581ce1d3bca63153d03c07a5ccbbfbe77de3bf561e75e85f9427dd32a3 fix(musehub#199): remove declare -A from push.sh for bash 3… Sonnet 5 minor ⚠ breaking 21 hours ago
1 #!/usr/bin/env bash
2 # MuseHub deploy orchestrator — build, push to ECR, trigger blue-green via SSM.
3 #
4 # Usage:
5 # bash deploy/push.sh staging # deploy to staging only
6 # bash deploy/push.sh prod # deploy to prod only
7 # bash deploy/push.sh staging prod # staging first, then prod
8 #
9 # What it does:
10 # 1. Builds a linux/amd64 Docker image from the local repo.
11 # 2. Tags it with <commit-hash>-<timestamp> for traceability.
12 # 3. Pushes the image to each target's own ECR (staging and prod are
13 # separate AWS accounts with separate, non-shared ECR repos).
14 # 4. Sends an SSM command to each target instance to run deploy.sh,
15 # which pulls the image and performs a zero-downtime blue-green swap.
16 # 5. Polls SSM until the deploy completes or fails, streaming the output.
17 #
18 # Staging and production are different AWS accounts, each authenticated via
19 # the operator's own IAM Identity Center SSO session — run `aws sso login
20 # --profile musehub-nonproduction` (staging) or `--profile musehub-production`
21 # (prod) first. No shared IAM user is used by either target; the legacy
22 # `musehub-infra` static credential is retired from this path (kept around
23 # for now for any scripts that still reference it directly, but no longer
24 # used by push.sh).
25 #
26 # Prerequisites (one-time, already done):
27 # - `musehub-nonproduction` and `musehub-production` SSO profiles configured
28 # in ~/.aws/config
29 # - Docker Desktop running
30 # - musehub-ec2-ssm / musehub-production-ec2-ssm roles have ecr pull permissions
31 # - AWS CLI installed on instances (run deploy/bootstrap-instance.sh once)
32 #
33 # Rollback to a previous image:
34 # IMAGE_TAG=<previous-tag> bash deploy/push.sh staging
35 # (skips build+push, triggers SSM with the specified tag directly)
36
37 set -euo pipefail
38
39 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
40 REPO_DIR="$(dirname "$SCRIPT_DIR")"
41 ECOSYSTEM_DIR="$(dirname "$REPO_DIR")"
42
43 ECR_REPO="musehub/musehub"
44 REGION="us-east-1"
45
46 # Per-environment config — staging and prod are separate AWS accounts.
47 #
48 # Deliberately `case` dispatch rather than `declare -A`: associative arrays
49 # require bash 4+, but macOS ships bash 3.2 as /bin/bash with no version
50 # guard elsewhere in this script, so `declare -A` fails outright for anyone
51 # running under the system bash rather than a Homebrew-installed bash 4/5.
52 instance_id_for() {
53 case "$1" in
54 staging) echo "i-07547cd20bee2dea5" ;;
55 prod) echo "i-043aaed71bef11903" ;;
56 esac
57 }
58 ecr_registry_for() {
59 case "$1" in
60 staging) echo "992382692655.dkr.ecr.us-east-1.amazonaws.com" ;;
61 prod) echo "672469410277.dkr.ecr.us-east-1.amazonaws.com" ;;
62 esac
63 }
64 aws_profile_for() {
65 case "$1" in
66 staging) echo "musehub-nonproduction" ;; # operator's own SSO session
67 prod) echo "musehub-production" ;; # operator's own SSO session
68 esac
69 }
70
71 # ── Parse targets ─────────────────────────────────────────────────────────────
72
73 if [ $# -eq 0 ]; then
74 echo "Usage: bash deploy/push.sh [staging] [prod]"
75 echo " staging deploy to staging.musehub.ai"
76 echo " prod deploy to musehub.ai"
77 echo " staging prod staging first, then prod"
78 echo ""
79 echo "Rollback (skips build+push, redeploys a previous tag):"
80 echo " IMAGE_TAG=<tag> bash deploy/push.sh staging"
81 exit 1
82 fi
83
84 TARGETS=()
85 for arg in "$@"; do
86 case "$arg" in
87 staging|prod) TARGETS+=("$arg") ;;
88 *) echo "Unknown target: $arg (must be staging or prod)" >&2; exit 1 ;;
89 esac
90 done
91
92 # ── Helpers ───────────────────────────────────────────────────────────────────
93
94 log() { echo "[push] $*"; }
95 die() { echo "[push] ERROR: $*" >&2; exit 1; }
96
97 profile_args() {
98 local env="$1"
99 local profile
100 profile="$(aws_profile_for "$env")"
101 if [ -n "$profile" ]; then
102 echo "--profile $profile"
103 fi
104 }
105
106 check_auth() {
107 local env="$1"
108 local profile
109 profile="$(aws_profile_for "$env")"
110 if [ -n "$profile" ]; then
111 if ! aws sts get-caller-identity --profile "$profile" > /dev/null 2>&1; then
112 die "No valid SSO session for profile '$profile'. Run: aws sso login --profile $profile"
113 fi
114 fi
115 }
116
117 # ── Image tag ─────────────────────────────────────────────────────────────────
118
119 # If IMAGE_TAG is already set (rollback mode), skip build+push.
120 if [ -n "${IMAGE_TAG:-}" ]; then
121 log "Rollback mode — using existing tag: $IMAGE_TAG"
122 SKIP_BUILD=true
123 else
124 COMMIT_HASH=$(muse -C "$REPO_DIR" rev-parse HEAD --json 2>/dev/null \
125 | python3 -c "import sys,json; cid=json.load(sys.stdin)['commit_id']; print(cid.removeprefix('sha256:')[:8])" \
126 2>/dev/null || echo "local")
127 IMAGE_TAG="${COMMIT_HASH}-$(date +%Y%m%d%H%M%S)"
128 SKIP_BUILD=false
129 fi
130
131 log "Image tag: $IMAGE_TAG"
132
133 for target in "${TARGETS[@]}"; do
134 check_auth "$target"
135 done
136
137 # ── Build ─────────────────────────────────────────────────────────────────────
138
139 LOCAL_TAG="musehub-build:${IMAGE_TAG}"
140 CRANE_TAR="/tmp/musehub-${IMAGE_TAG}.tar"
141
142 if [ "$SKIP_BUILD" = false ]; then
143 log "[1/3] Building image for linux/amd64..."
144 docker build \
145 --platform linux/amd64 \
146 --tag "$LOCAL_TAG" \
147 -f "$REPO_DIR/Dockerfile" \
148 "$ECOSYSTEM_DIR"
149 log "Build complete."
150 docker save "$LOCAL_TAG" -o "$CRANE_TAR"
151
152 # ── Push to each target's ECR via crane ───────────────────────────────────
153 # crane bypasses Docker Desktop's VPNKit proxy, which drops connections
154 # mid-upload on large layer pushes. Never use `docker push` to ECR.
155 # Staging and prod are separate AWS accounts, so the same tarball is
156 # pushed to each target's own registry under its own credentials.
157
158 log "[2/3] Pushing to ECR for: ${TARGETS[*]}"
159 for target in "${TARGETS[@]}"; do
160 registry="$(ecr_registry_for "$target")"
161 image="${registry}/${ECR_REPO}"
162 eval "aws ecr get-login-password --region \"$REGION\" $(profile_args "$target")" | \
163 crane auth login "$registry" --username AWS --password-stdin
164 crane push "$CRANE_TAR" "${image}:${IMAGE_TAG}"
165 crane push "$CRANE_TAR" "${image}:latest"
166 log " Pushed to $target ($registry)"
167 done
168 rm -f "$CRANE_TAR"
169 log "Push complete. Tag: $IMAGE_TAG"
170 else
171 log "[1/3] Skipping build (rollback mode)."
172 log "[2/3] Skipping push (rollback mode) — assumes the tag already exists in each target registry."
173 fi
174
175 # ── Trigger deploy via SSM ────────────────────────────────────────────────────
176
177 log "[3/3] Triggering deploy on: ${TARGETS[*]}"
178
179 deploy_to() {
180 local env="$1"
181 local instance_id
182 instance_id="$(instance_id_for "$env")"
183 local ecr_image
184 ecr_image="$(ecr_registry_for "$env")/${ECR_REPO}"
185 local pargs
186 pargs=$(profile_args "$env")
187
188 # deploy.sh's MUSEHUB_ENV drives its CloudWatch log group — "prod" (the
189 # CLI target name) must map to "production" (the actual env/log-group name).
190 local musehub_env
191 if [ "$env" = "prod" ]; then musehub_env="production"; else musehub_env="staging"; fi
192
193 log ""
194 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
195 log "→ Deploying to $env ($instance_id)"
196 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
197
198 local deploy_sh_b64
199 deploy_sh_b64=$(base64 -i "$SCRIPT_DIR/deploy.sh" | tr -d '\n')
200
201 local nginx_conf_b64
202 nginx_conf_b64=$(base64 -i "$SCRIPT_DIR/nginx-cf.conf" | tr -d '\n')
203
204 local set_slot_b64
205 set_slot_b64=$(base64 -i "$SCRIPT_DIR/set-active-slot.sh" | tr -d '\n')
206
207 # ── Fire the deploy command ───────────────────────────────────────────────
208 local deploy_cmd_id
209 deploy_cmd_id=$(eval "aws ssm send-command \
210 --region \"$REGION\" \
211 $pargs \
212 --instance-ids \"$instance_id\" \
213 --document-name \"AWS-RunShellScript\" \
214 --parameters \"commands=[
215 \\\"echo '${deploy_sh_b64}' | base64 -d > /opt/musehub/deploy/deploy.sh && chmod +x /opt/musehub/deploy/deploy.sh\\\",
216 \\\"echo '${nginx_conf_b64}' | base64 -d > /opt/musehub/deploy/nginx-cf.conf\\\",
217 \\\"echo '${set_slot_b64}' | base64 -d > /usr/local/bin/musehub-set-slot && chmod +x /usr/local/bin/musehub-set-slot\\\",
218 \\\"export ECR_IMAGE=${ecr_image}\\\",
219 \\\"export IMAGE_TAG=${IMAGE_TAG}\\\",
220 \\\"export MUSEHUB_ENV=${musehub_env}\\\",
221 \\\"bash /opt/musehub/deploy/deploy.sh\\\"
222 ]\" \
223 --comment \"musehub ${IMAGE_TAG} → ${env}\" \
224 --timeout-seconds 600 \
225 --query \"Command.CommandId\" \
226 --output text")
227
228 log " SSM command: $deploy_cmd_id"
229
230 # ── Stream deploy output live ─────────────────────────────────────────────
231 # SSM updates StandardOutputContent as the command runs. We poll every 5s,
232 # diff against what we've already printed, and show new lines immediately.
233 log " Live output:"
234 log ""
235
236 local lines_seen=0
237 local elapsed=0
238 local final_status=""
239
240 while true; do
241 sleep 5
242 elapsed=$((elapsed + 5))
243
244 local invocation
245 invocation=$(eval "aws ssm get-command-invocation \
246 --region \"$REGION\" \
247 $pargs \
248 --command-id \"$deploy_cmd_id\" \
249 --instance-id \"$instance_id\" \
250 --output json" 2>/dev/null || echo '{"Status":"Pending","StandardOutputContent":"","StandardErrorContent":""}')
251
252 local cmd_status all_stdout all_stderr
253 cmd_status=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Status','Pending'))" 2>/dev/null || echo "Pending")
254 all_stdout=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardOutputContent',''),end='')" 2>/dev/null || echo "")
255 all_stderr=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardErrorContent',''),end='')" 2>/dev/null || echo "")
256
257 # Print any new stdout lines
258 local total_lines
259 total_lines=$(echo "$all_stdout" | wc -l)
260 if [ "$total_lines" -gt "$lines_seen" ]; then
261 echo "$all_stdout" | tail -n +"$((lines_seen + 1))"
262 lines_seen=$total_lines
263 fi
264
265 case "$cmd_status" in
266 Success)
267 final_status="success"
268 break
269 ;;
270 Failed|Cancelled|TimedOut|Cancelling)
271 final_status="failed"
272 [ -n "$all_stderr" ] && echo "STDERR: $all_stderr"
273 break
274 ;;
275 esac
276
277 if [ "$elapsed" -ge 600 ]; then
278 die "Deploy timed out after 10 min. SSM command: $deploy_cmd_id"
279 fi
280 done
281
282 echo ""
283 if [ "$final_status" = "success" ]; then
284 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
285 log "✅ $env deploy succeeded."
286 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
287 return 0
288 else
289 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
290 log "❌ $env deploy FAILED. Full output:"
291 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
292 eval "aws ssm get-command-invocation \
293 --region \"$REGION\" \
294 $pargs \
295 --command-id \"$deploy_cmd_id\" \
296 --instance-id \"$instance_id\" \
297 --query \"[StandardOutputContent,StandardErrorContent]\" \
298 --output text" 2>/dev/null || true
299 return 1
300 fi
301 }
302
303 for target in "${TARGETS[@]}"; do
304 deploy_to "$target"
305 done
306
307 log ""
308 log "All done."
309 log " Tag: $IMAGE_TAG"
File History 1 commit
sha256:915b1f581ce1d3bca63153d03c07a5ccbbfbe77de3bf561e75e85f9427dd32a3 fix(musehub#199): remove declare -A from push.sh for bash 3… Sonnet 5 minor 21 hours ago