gabriel / musehub public
push.sh bash
295 lines 12.4 KB
Raw
sha256:8e05daa29ba6702b4a2380a16d690ba31cc099d69c5c859bc6e6f16a0e945f99 Merge 'fix/deploy-memory-limits-and-log-group' into 'dev' —… Human 3 days 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 with different auth:
19 # - staging (Nonproduction, 992382692655): musehub-infra IAM user
20 # (default AWS CLI profile — long-lived shared credential, legacy)
21 # - prod (Production, 672469410277): the operator's own IAM Identity
22 # Center SSO session — run `aws sso login --profile musehub-production`
23 # first. No shared IAM user exists in Production by design.
24 #
25 # Prerequisites (one-time, already done):
26 # - AWS CLI configured (musehub-infra as default profile, for staging)
27 # - `musehub-production` SSO profile configured in ~/.aws/config, for prod
28 # - Docker Desktop running
29 # - musehub-infra has ecr push permissions (musehub-ecr-push policy)
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 declare -A INSTANCE=(
48 [staging]="i-07547cd20bee2dea5"
49 [prod]="i-043aaed71bef11903"
50 )
51 declare -A ECR_REGISTRY=(
52 [staging]="992382692655.dkr.ecr.us-east-1.amazonaws.com"
53 [prod]="672469410277.dkr.ecr.us-east-1.amazonaws.com"
54 )
55 declare -A AWS_PROFILE=(
56 [staging]="" # default profile (musehub-infra)
57 [prod]="musehub-production" # operator's own SSO session
58 )
59
60 # ── Parse targets ─────────────────────────────────────────────────────────────
61
62 if [ $# -eq 0 ]; then
63 echo "Usage: bash deploy/push.sh [staging] [prod]"
64 echo " staging deploy to staging.musehub.ai"
65 echo " prod deploy to musehub.ai"
66 echo " staging prod staging first, then prod"
67 echo ""
68 echo "Rollback (skips build+push, redeploys a previous tag):"
69 echo " IMAGE_TAG=<tag> bash deploy/push.sh staging"
70 exit 1
71 fi
72
73 TARGETS=()
74 for arg in "$@"; do
75 case "$arg" in
76 staging|prod) TARGETS+=("$arg") ;;
77 *) echo "Unknown target: $arg (must be staging or prod)" >&2; exit 1 ;;
78 esac
79 done
80
81 # ── Helpers ───────────────────────────────────────────────────────────────────
82
83 log() { echo "[push] $*"; }
84 die() { echo "[push] ERROR: $*" >&2; exit 1; }
85
86 profile_args() {
87 local env="$1"
88 local profile="${AWS_PROFILE[$env]}"
89 if [ -n "$profile" ]; then
90 echo "--profile $profile"
91 fi
92 }
93
94 check_auth() {
95 local env="$1"
96 local profile="${AWS_PROFILE[$env]}"
97 if [ -n "$profile" ]; then
98 if ! aws sts get-caller-identity --profile "$profile" > /dev/null 2>&1; then
99 die "No valid SSO session for profile '$profile'. Run: aws sso login --profile $profile"
100 fi
101 fi
102 }
103
104 # ── Image tag ─────────────────────────────────────────────────────────────────
105
106 # If IMAGE_TAG is already set (rollback mode), skip build+push.
107 if [ -n "${IMAGE_TAG:-}" ]; then
108 log "Rollback mode — using existing tag: $IMAGE_TAG"
109 SKIP_BUILD=true
110 else
111 COMMIT_HASH=$(muse -C "$REPO_DIR" rev-parse HEAD --json 2>/dev/null \
112 | python3 -c "import sys,json; cid=json.load(sys.stdin)['commit_id']; print(cid.removeprefix('sha256:')[:8])" \
113 2>/dev/null || echo "local")
114 IMAGE_TAG="${COMMIT_HASH}-$(date +%Y%m%d%H%M%S)"
115 SKIP_BUILD=false
116 fi
117
118 log "Image tag: $IMAGE_TAG"
119
120 for target in "${TARGETS[@]}"; do
121 check_auth "$target"
122 done
123
124 # ── Build ─────────────────────────────────────────────────────────────────────
125
126 LOCAL_TAG="musehub-build:${IMAGE_TAG}"
127 CRANE_TAR="/tmp/musehub-${IMAGE_TAG}.tar"
128
129 if [ "$SKIP_BUILD" = false ]; then
130 log "[1/3] Building image for linux/amd64..."
131 docker build \
132 --platform linux/amd64 \
133 --tag "$LOCAL_TAG" \
134 -f "$REPO_DIR/Dockerfile" \
135 "$ECOSYSTEM_DIR"
136 log "Build complete."
137 docker save "$LOCAL_TAG" -o "$CRANE_TAR"
138
139 # ── Push to each target's ECR via crane ───────────────────────────────────
140 # crane bypasses Docker Desktop's VPNKit proxy, which drops connections
141 # mid-upload on large layer pushes. Never use `docker push` to ECR.
142 # Staging and prod are separate AWS accounts, so the same tarball is
143 # pushed to each target's own registry under its own credentials.
144
145 log "[2/3] Pushing to ECR for: ${TARGETS[*]}"
146 for target in "${TARGETS[@]}"; do
147 registry="${ECR_REGISTRY[$target]}"
148 image="${registry}/${ECR_REPO}"
149 eval "aws ecr get-login-password --region \"$REGION\" $(profile_args "$target")" | \
150 crane auth login "$registry" --username AWS --password-stdin
151 crane push "$CRANE_TAR" "${image}:${IMAGE_TAG}"
152 crane push "$CRANE_TAR" "${image}:latest"
153 log " Pushed to $target ($registry)"
154 done
155 rm -f "$CRANE_TAR"
156 log "Push complete. Tag: $IMAGE_TAG"
157 else
158 log "[1/3] Skipping build (rollback mode)."
159 log "[2/3] Skipping push (rollback mode) — assumes the tag already exists in each target registry."
160 fi
161
162 # ── Trigger deploy via SSM ────────────────────────────────────────────────────
163
164 log "[3/3] Triggering deploy on: ${TARGETS[*]}"
165
166 deploy_to() {
167 local env="$1"
168 local instance_id="${INSTANCE[$env]}"
169 local ecr_image="${ECR_REGISTRY[$env]}/${ECR_REPO}"
170 local profile="${AWS_PROFILE[$env]}"
171 local pargs
172 pargs=$(profile_args "$env")
173
174 # deploy.sh's MUSEHUB_ENV drives its CloudWatch log group — "prod" (the
175 # CLI target name) must map to "production" (the actual env/log-group name).
176 local musehub_env
177 if [ "$env" = "prod" ]; then musehub_env="production"; else musehub_env="staging"; fi
178
179 log ""
180 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
181 log "→ Deploying to $env ($instance_id)"
182 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
183
184 local deploy_sh_b64
185 deploy_sh_b64=$(base64 -i "$SCRIPT_DIR/deploy.sh" | tr -d '\n')
186
187 local nginx_conf_b64
188 nginx_conf_b64=$(base64 -i "$SCRIPT_DIR/nginx-cf.conf" | tr -d '\n')
189
190 local set_slot_b64
191 set_slot_b64=$(base64 -i "$SCRIPT_DIR/set-active-slot.sh" | tr -d '\n')
192
193 # ── Fire the deploy command ───────────────────────────────────────────────
194 local deploy_cmd_id
195 deploy_cmd_id=$(eval "aws ssm send-command \
196 --region \"$REGION\" \
197 $pargs \
198 --instance-ids \"$instance_id\" \
199 --document-name \"AWS-RunShellScript\" \
200 --parameters \"commands=[
201 \\\"echo '${deploy_sh_b64}' | base64 -d > /opt/musehub/deploy/deploy.sh && chmod +x /opt/musehub/deploy/deploy.sh\\\",
202 \\\"echo '${nginx_conf_b64}' | base64 -d > /opt/musehub/deploy/nginx-cf.conf\\\",
203 \\\"echo '${set_slot_b64}' | base64 -d > /usr/local/bin/musehub-set-slot && chmod +x /usr/local/bin/musehub-set-slot\\\",
204 \\\"export ECR_IMAGE=${ecr_image}\\\",
205 \\\"export IMAGE_TAG=${IMAGE_TAG}\\\",
206 \\\"export MUSEHUB_ENV=${musehub_env}\\\",
207 \\\"bash /opt/musehub/deploy/deploy.sh\\\"
208 ]\" \
209 --comment \"musehub ${IMAGE_TAG} → ${env}\" \
210 --timeout-seconds 600 \
211 --query \"Command.CommandId\" \
212 --output text")
213
214 log " SSM command: $deploy_cmd_id"
215
216 # ── Stream deploy output live ─────────────────────────────────────────────
217 # SSM updates StandardOutputContent as the command runs. We poll every 5s,
218 # diff against what we've already printed, and show new lines immediately.
219 log " Live output:"
220 log ""
221
222 local lines_seen=0
223 local elapsed=0
224 local final_status=""
225
226 while true; do
227 sleep 5
228 elapsed=$((elapsed + 5))
229
230 local invocation
231 invocation=$(eval "aws ssm get-command-invocation \
232 --region \"$REGION\" \
233 $pargs \
234 --command-id \"$deploy_cmd_id\" \
235 --instance-id \"$instance_id\" \
236 --output json" 2>/dev/null || echo '{"Status":"Pending","StandardOutputContent":"","StandardErrorContent":""}')
237
238 local cmd_status all_stdout all_stderr
239 cmd_status=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('Status','Pending'))" 2>/dev/null || echo "Pending")
240 all_stdout=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardOutputContent',''),end='')" 2>/dev/null || echo "")
241 all_stderr=$(echo "$invocation" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('StandardErrorContent',''),end='')" 2>/dev/null || echo "")
242
243 # Print any new stdout lines
244 local total_lines
245 total_lines=$(echo "$all_stdout" | wc -l)
246 if [ "$total_lines" -gt "$lines_seen" ]; then
247 echo "$all_stdout" | tail -n +"$((lines_seen + 1))"
248 lines_seen=$total_lines
249 fi
250
251 case "$cmd_status" in
252 Success)
253 final_status="success"
254 break
255 ;;
256 Failed|Cancelled|TimedOut|Cancelling)
257 final_status="failed"
258 [ -n "$all_stderr" ] && echo "STDERR: $all_stderr"
259 break
260 ;;
261 esac
262
263 if [ "$elapsed" -ge 600 ]; then
264 die "Deploy timed out after 10 min. SSM command: $deploy_cmd_id"
265 fi
266 done
267
268 echo ""
269 if [ "$final_status" = "success" ]; then
270 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
271 log "✅ $env deploy succeeded."
272 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
273 return 0
274 else
275 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
276 log "❌ $env deploy FAILED. Full output:"
277 log "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
278 eval "aws ssm get-command-invocation \
279 --region \"$REGION\" \
280 $pargs \
281 --command-id \"$deploy_cmd_id\" \
282 --instance-id \"$instance_id\" \
283 --query \"[StandardOutputContent,StandardErrorContent]\" \
284 --output text" 2>/dev/null || true
285 return 1
286 fi
287 }
288
289 for target in "${TARGETS[@]}"; do
290 deploy_to "$target"
291 done
292
293 log ""
294 log "All done."
295 log " Tag: $IMAGE_TAG"
File History 16 commits
sha256:8e05daa29ba6702b4a2380a16d690ba31cc099d69c5c859bc6e6f16a0e945f99 Merge 'fix/deploy-memory-limits-and-log-group' into 'dev' —… Human 3 days ago
sha256:5528fe0d7ff3bfcea26d42b3c6e2a7f72127d57444f44cbb23761a869d0961f0 Merge 'docs/multi-remote-workflow' into 'dev' — proposal: d… Human 3 days ago
sha256:20ae2cb8b3425921e96f2131287a153721794504a8d0688f67cc2e3cd158e3ef Merge 'fix/production-backups-r2-bucket' into 'dev' — propo… Human 3 days ago
sha256:fc04e4cae9e1774d6a21b65c45daeed0e6787eb581d13aa1b03bfe9384a34226 Merge branch 'fix/two-column-scroll-layout' into dev Human 55 days ago
sha256:408916fc5973ba59c6e4eebaa80ebdcc801c0a63205651e25009d11548f79454 chore: bump version to 0.2.0.dev2 — nightly.2, matching muse Sonnet 4.6 patch 58 days ago
sha256:d035733f21ccff27735fddebfbbe0ed24565a32a22db8de5885402262671ecd2 chore: bump version to 0.2.0rc15 for musehub#113 fix release Sonnet 4.6 patch 61 days ago
sha256:0032d6cfa33bc3c8367436ad768e7dd0e339b4332153160247da8266cb5fa352 Merge branch 'task/version-tags-phase3-server' into dev Human 63 days ago
sha256:4669620efda9ff41c55bdefd1f7bfe1c239d468428744c84ead9957e5a003a53 merge: rescue snapshot-recovery hardening (c00aa21d) into d… Opus 4.8 minor 76 days ago
sha256:a59da49c4611b970fc4b6ae48678ce4943261c213a07ddbd73ce9201df869b4a fix: remove false-positive proposal_comments index drop fro… Sonnet 4.6 patch 80 days ago
sha256:0a240d6dbff234f07d98a28a4a9a68db702f3f9ff9260196f24219bdb1c0b6f3 feat: render markdown mists as HTML with heading anchor links Sonnet 4.6 patch 81 days ago
sha256:24a7d47486ebc4ebd1832830580e177ec6f877b48dced8c000e198cdec4ce9d6 Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 82 days ago
sha256:b9ff931d147e0114a1f17060f415b89ed551c170a91ff226c70437aa5c85f9ee Merge 'task/bump-version-rc12' into 'dev' — proposal: Bump … Human 82 days ago
sha256:d1122d21e73471879b460037b22c0b50fded7c423444a176f248428f75dac39c Merge 'task/fix-issue-pagination-cursor' into 'dev' — propo… Human 82 days ago
sha256:01e18975e73d2b3cd5b6db7929c895bef9aa6e0d4391dc5b2adfc548b41318dd Merge 'feat/adding-debug-logs-to-staging' into 'dev' — prop… Human 82 days ago
sha256:6b1949fc2797ca4c1936a637a4cbfec828ef56cf52398a2e74ca3c4f494e728f fix: use wire_bytes not mpack_bytes_raw in compute_object_b… Sonnet 4.6 patch 94 days ago
sha256:b99f2455dc346966d040133f5203297e6e3ef5803a93728a2c30568d0a0f7583 rename: delta_add → delta_upsert across wire format, models… Sonnet 4.6 patch 96 days ago