11#! /usr/bin/env bash
22# Waits for the ECS blue/green deploy triggered by a specific app image push to
3- # reach its traffic cutover (CodeDeploy AllowTraffic == Succeeded), then exits 0.
3+ # reach its traffic cutover (CodeDeploy AllowTraffic == Succeeded on every ECS
4+ # target), then exits 0.
45#
56# ECR app images use a floating tag (latest/staging) with no git SHA, so the
67# only durable key linking this CI push to its ECS deploy is the image DIGEST.
7- # Correlation: image digest -> CodePipeline execution (AppEcrImage revision) ->
8+ # Correlation: image digest -> CodePipeline execution (ECR_Source revision) ->
89# Deploy action externalExecutionId (== CodeDeploy deployment id) -> AllowTraffic.
910#
10- # Usage: wait-for-ecs-cutover.sh <pipeline-name> <image-digest>
11- # Requires: awscli v2, configured credentials with codedeploy + codepipeline read.
11+ # The digest alone is ambiguous: a prior run with the same image could match an
12+ # older, already-cutover execution and promote too early. SINCE_EPOCH (the time
13+ # the deploy tag was retagged, i.e. when THIS push's pipeline was triggered)
14+ # disambiguates — only an execution that started at/after the retag is ours.
15+ #
16+ # Usage: wait-for-ecs-cutover.sh <pipeline-name> <image-digest> <since-epoch>
17+ # Requires: awscli v2, python3, credentials with codedeploy + codepipeline read.
1218set -euo pipefail
1319
1420PIPELINE=" ${1:? pipeline name required} "
1521DIGEST=" ${2:? image digest required} "
22+ SINCE_EPOCH=" ${3:? since-epoch (retag time) required} "
1623
1724POLL_INTERVAL=" ${POLL_INTERVAL:- 15} "
1825# 70 min covers a prod deploy whose Deploy stage is queued behind a prior
1926# deploy's ~50-min termination bake before its own traffic shift begins.
2027OVERALL_TIMEOUT=" ${OVERALL_TIMEOUT:- 4200} "
28+ # Tolerate minor clock skew between the runner (retag time) and CodePipeline.
29+ SINCE_SKEW=" ${SINCE_SKEW:- 120} "
2130
2231deadline=$(( $(date +% s) + OVERALL_TIMEOUT ))
2332remaining () { echo $(( deadline - $(date +% s) )) ; }
@@ -31,21 +40,44 @@ fail_if_expired() {
3140
3241log " Pipeline: $PIPELINE "
3342log " Target app image digest: $DIGEST "
43+ log " Requiring execution started at/after epoch $SINCE_EPOCH (minus ${SINCE_SKEW} s skew)"
3444
35- # Phase A: find the pipeline execution whose ECR source revision matches our
36- # digest. --max-items bounds the fetch (the CLI otherwise auto-paginates the whole
37- # execution history); our push is the newest execution, so it's on the first page.
38- # The revisionId match is done server-side via JMESPath; grep isolates the UUID
39- # from any trailing pagination-token line in text output.
45+ # Phase A: find the newest pipeline execution whose ECR source revision matches
46+ # our digest AND that started at/after the retag. The since filter rejects a
47+ # stale historical execution reusing the same digest. --max-items bounds the
48+ # fetch (the CLI otherwise auto-paginates the whole history).
4049EXECUTION_ID=" "
4150while [ -z " $EXECUTION_ID " ]; do
42- fail_if_expired " pipeline execution matching digest"
43- EXECUTION_ID =$( aws codepipeline list-pipeline-executions \
51+ fail_if_expired " pipeline execution matching digest since retag "
52+ matches =$( aws codepipeline list-pipeline-executions \
4453 --pipeline-name " $PIPELINE " --max-items 30 \
45- --query " pipelineExecutionSummaries[?sourceRevisions[?actionName=='ECR_Source' && revisionId=='$DIGEST ']].pipelineExecutionId" \
46- --output text 2> /dev/null | tr ' \t ' ' \n\n' | grep -Em1 ' ^[0-9a-f-]{36}$' || true)
54+ --query " pipelineExecutionSummaries[?sourceRevisions[?actionName=='ECR_Source' && revisionId=='$DIGEST ']].[startTime, pipelineExecutionId]" \
55+ --output text 2> /dev/null || true)
56+ EXECUTION_ID=$( printf ' %s\n' " $matches " | SINCE=" $SINCE_EPOCH " SKEW=" $SINCE_SKEW " python3 -c '
57+ import sys, os, datetime
58+ since = float(os.environ["SINCE"]) - float(os.environ["SKEW"])
59+ best_epoch = None
60+ best_id = None
61+ for line in sys.stdin:
62+ parts = line.rstrip("\n").split("\t")
63+ if len(parts) < 2:
64+ continue
65+ ts, eid = parts[0].strip(), parts[1].strip()
66+ if not ts or not eid or "-" not in eid:
67+ continue
68+ try:
69+ epoch = float(ts)
70+ except ValueError:
71+ try:
72+ epoch = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
73+ except ValueError:
74+ continue
75+ if epoch >= since and (best_epoch is None or epoch > best_epoch):
76+ best_epoch, best_id = epoch, eid
77+ print(best_id or "")
78+ ' 2> /dev/null || true)
4779 if [ -z " $EXECUTION_ID " ]; then
48- log " No matching pipeline execution yet; retry in ${POLL_INTERVAL} s (remaining $( remaining) s)"
80+ log " No matching post-retag pipeline execution yet; retry in ${POLL_INTERVAL} s (remaining $( remaining) s)"
4981 sleep " $POLL_INTERVAL "
5082 fi
5183done
@@ -77,9 +109,11 @@ while [ -z "$DEPLOYMENT_ID" ] || [ "$DEPLOYMENT_ID" = "None" ]; do
77109done
78110log " CodeDeploy deployment: $DEPLOYMENT_ID "
79111
80- # Phase C: wait for the traffic cutover (AllowTraffic lifecycle event Succeeded).
112+ # Phase C: wait for the traffic cutover. Require AllowTraffic == Succeeded on
113+ # EVERY ECS target, so a multi-target deploy can't promote while one target is
114+ # still mid-cutover or failed.
81115while true ; do
82- fail_if_expired " AllowTraffic (traffic cutover)"
116+ fail_if_expired " AllowTraffic (traffic cutover) on all targets "
83117 dstatus=$( aws deploy get-deployment --deployment-id " $DEPLOYMENT_ID " \
84118 --query ' deploymentInfo.status' --output text 2> /dev/null || true)
85119 case " $dstatus " in
@@ -88,18 +122,25 @@ while true; do
88122 exit 1
89123 ;;
90124 esac
91- target_id=$( aws deploy list-deployment-targets --deployment-id " $DEPLOYMENT_ID " \
92- --query ' targetIds[0]' --output text 2> /dev/null || true)
93- at_status=" "
94- if [ -n " $target_id " ] && [ " $target_id " != " None" ]; then
95- at_status=$( aws deploy get-deployment-target --deployment-id " $DEPLOYMENT_ID " --target-id " $target_id " \
96- --query " deploymentTarget.ecsTarget.lifecycleEvents[?lifecycleEventName=='AllowTraffic'].status | [0]" \
97- --output text 2> /dev/null || true)
98- if [ " $at_status " = " Succeeded" ]; then
99- log " Traffic cutover complete (AllowTraffic Succeeded) for $DEPLOYMENT_ID "
125+ target_ids=$( aws deploy list-deployment-targets --deployment-id " $DEPLOYMENT_ID " \
126+ --query ' targetIds' --output text 2> /dev/null || true)
127+ if [ -n " $target_ids " ] && [ " $target_ids " != " None" ]; then
128+ all_ok=1
129+ ntargets=0
130+ for tid in $target_ids ; do
131+ ntargets=$(( ntargets + 1 ))
132+ at_status=$( aws deploy get-deployment-target --deployment-id " $DEPLOYMENT_ID " --target-id " $tid " \
133+ --query " deploymentTarget.ecsTarget.lifecycleEvents[?lifecycleEventName=='AllowTraffic'].status | [0]" \
134+ --output text 2> /dev/null || true)
135+ if [ " $at_status " != " Succeeded" ]; then
136+ all_ok=0
137+ fi
138+ done
139+ if [ " $ntargets " -gt 0 ] && [ " $all_ok " = " 1" ]; then
140+ log " Traffic cutover complete (AllowTraffic Succeeded on all $ntargets target(s)) for $DEPLOYMENT_ID "
100141 exit 0
101142 fi
102143 fi
103- log " Deployment $DEPLOYMENT_ID status=$dstatus AllowTraffic= ${at_status :- pending} ; wait ${POLL_INTERVAL} s (remaining $( remaining) s)"
144+ log " Deployment $DEPLOYMENT_ID status=$dstatus ; not all targets past AllowTraffic; wait ${POLL_INTERVAL} s (remaining $( remaining) s)"
104145 sleep " $POLL_INTERVAL "
105146done
0 commit comments