Skip to content

Commit da56970

Browse files
fix(ci): verify Trigger promotion against the deployed image and cutover
1 parent 81d73da commit da56970

6 files changed

Lines changed: 376 additions & 248 deletions

File tree

.github/actions/docker-build/action.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ inputs:
3030
bypass an input `default:` entirely.
3131
required: false
3232

33+
outputs:
34+
digest:
35+
description: The image digest returned by the selected build provider.
36+
value: ${{ steps.build-blacksmith.outputs.digest || steps.build-github.outputs.digest }}
37+
3338
# Registry logins must precede this action. provenance/sbom stay off: attestation
3439
# manifests break `imagetools create` retagging in promote-images.
3540
runs:
@@ -64,6 +69,7 @@ runs:
6469
cache-key: ${{ steps.cache-key.outputs.value }}
6570

6671
- name: Build and push (Blacksmith)
72+
id: build-blacksmith
6773
if: inputs.provider == '' || inputs.provider == 'blacksmith'
6874
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
6975
with:
@@ -169,6 +175,7 @@ runs:
169175

170176
# No cache-to: type=gha — it shares the 10 GB repo quota with the cache mounts.
171177
- name: Build and push (GitHub)
178+
id: build-github
172179
if: inputs.provider != '' && inputs.provider != 'blacksmith'
173180
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
174181
with:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# Read one ECR tag. Only ImageNotFound is optional; AWS and response errors fail.
3+
set -euo pipefail
4+
REPOSITORY="${1:?repository required}"
5+
TAG="${2:?tag required}"
6+
ALLOW_MISSING="${3:-}"
7+
if [ -n "$ALLOW_MISSING" ] && [ "$ALLOW_MISSING" != '--allow-missing' ]; then
8+
echo 'ERROR: expected --allow-missing or no third argument' >&2
9+
exit 1
10+
fi
11+
export AWS_PAGER=''
12+
aws ecr batch-get-image --repository-name "$REPOSITORY" --image-ids imageTag="$TAG" --output json |
13+
ALLOW_MISSING="$ALLOW_MISSING" python3 -c '
14+
import json, os, re, sys
15+
response = json.load(sys.stdin)
16+
images, failures = response["images"], response["failures"]
17+
if failures:
18+
if not images and len(failures) == 1 and failures[0]["failureCode"] == "ImageNotFound" and os.environ["ALLOW_MISSING"]:
19+
print("")
20+
sys.exit(0)
21+
raise SystemExit("ERROR: ECR image lookup failed: " + ", ".join(f["failureCode"] for f in failures))
22+
if len(images) != 1:
23+
raise SystemExit("ERROR: expected exactly one ECR image")
24+
digest = images[0]["imageId"]["imageDigest"]
25+
if not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
26+
raise SystemExit("ERROR: invalid ECR image digest")
27+
print(digest)
28+
'
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
"""Exercise the deployment gates with scripted AWS responses; no live mutations."""
2+
import json
3+
import os
4+
from pathlib import Path
5+
import subprocess
6+
import tempfile
7+
import unittest
8+
9+
SCRIPTS = Path(__file__).resolve().parent
10+
DIGEST = 'sha256:' + 'a' * 64
11+
OTHER_DIGEST = 'sha256:' + 'b' * 64
12+
13+
14+
def execution(start=1000, digest=DIGEST, identifier='execution-current'):
15+
return {
16+
'startTime': start,
17+
'pipelineExecutionId': identifier,
18+
'sourceRevisions': [{'actionName': 'ECR_Source', 'revisionId': digest}],
19+
}
20+
21+
22+
class DeploymentGateTests(unittest.TestCase):
23+
def run_script(self, script, args, responses):
24+
with tempfile.TemporaryDirectory() as directory:
25+
root = Path(directory)
26+
fixture = root / 'responses.json'
27+
fixture.write_text(json.dumps(responses))
28+
(root / 'aws').write_text('''#!/usr/bin/env python3
29+
import json, os, pathlib, sys
30+
root = pathlib.Path(os.environ['FIXTURE_DIR'])
31+
args = sys.argv[1:]
32+
if args[0] == '--cli-connect-timeout':
33+
args = args[4:]
34+
service, operation = args[:2]
35+
key = operation
36+
if operation == 'get-deployment-target':
37+
key += ':' + args[args.index('--target-id') + 1]
38+
with (root / 'calls').open('a') as stream:
39+
stream.write(' '.join(args) + '\\n')
40+
responses = json.loads((root / 'responses.json').read_text())
41+
if key not in responses:
42+
raise SystemExit('Unexpected AWS call: ' + key)
43+
response = responses[key]
44+
if isinstance(response, list):
45+
response = response.pop(0)
46+
responses[key] = response if not responses[key] else responses[key]
47+
(root / 'responses.json').write_text(json.dumps(responses))
48+
if response.get('error'):
49+
sys.stderr.write(response['error'])
50+
sys.exit(254)
51+
print(response.get('text', json.dumps(response.get('json'))))
52+
''')
53+
(root / 'date').write_text('''#!/usr/bin/env python3
54+
import os, pathlib
55+
path = pathlib.Path(os.environ['FIXTURE_DIR']) / 'clock'
56+
value = int(path.read_text()) if path.exists() else 1000
57+
path.write_text(str(value + 1))
58+
print(value)
59+
''')
60+
(root / 'sleep').write_text('#!/bin/sh\nexit 0\n')
61+
for name in ('aws', 'date', 'sleep'):
62+
(root / name).chmod(0o755)
63+
result = subprocess.run(
64+
['bash', str(SCRIPTS / script), *args],
65+
env={**os.environ, 'PATH': f'{root}:{os.environ["PATH"]}',
66+
'FIXTURE_DIR': str(root), 'POLL_INTERVAL': '1', 'OVERALL_TIMEOUT': '12'},
67+
capture_output=True, text=True, timeout=10,
68+
)
69+
calls = (root / 'calls').read_text() if (root / 'calls').exists() else ''
70+
return result, calls
71+
72+
def poll(self, updates=None, since='1000'):
73+
responses = {
74+
'list-pipeline-executions': {'json': [execution()]},
75+
'get-pipeline-execution': {'text': 'InProgress'},
76+
'list-action-executions': {'text': 'd-current'},
77+
'get-deployment': {'text': 'InProgress'},
78+
'list-deployment-targets': {'text': 'target-one\ttarget-two'},
79+
'get-deployment-target:target-one': {'text': 'Succeeded'},
80+
'get-deployment-target:target-two': {'text': 'Succeeded'},
81+
}
82+
responses.update(updates or {})
83+
return self.run_script('wait-for-ecs-cutover.sh', ['app-pipeline', DIGEST, since], responses)
84+
85+
def test_waits_for_every_target(self):
86+
result, calls = self.poll({'get-deployment-target:target-two': [
87+
{'text': 'InProgress'}, {'text': 'Succeeded'}]})
88+
self.assertEqual(result.returncode, 0, result.stderr)
89+
self.assertEqual(calls.count('--target-id target-two'), 2)
90+
91+
def test_rejects_stale_execution_inside_former_clock_skew_window(self):
92+
result, calls = self.poll({'list-pipeline-executions': {'json': [execution(start=999)]}})
93+
self.assertNotEqual(result.returncode, 0)
94+
self.assertIn('timed out', result.stdout)
95+
self.assertNotIn('get-pipeline-execution ', calls)
96+
97+
def test_chooses_newest_matching_execution(self):
98+
result, calls = self.poll({'list-pipeline-executions': {'json': [
99+
execution(1000, identifier='execution-old'), execution(1001)]}})
100+
self.assertEqual(result.returncode, 0, result.stderr)
101+
self.assertIn('--pipeline-execution-id execution-current', calls)
102+
103+
def test_iso_timestamps(self):
104+
result, _ = self.poll({'list-pipeline-executions': {'json': [
105+
execution('1970-01-01T00:16:40+00:00')]}})
106+
self.assertEqual(result.returncode, 0, result.stderr)
107+
108+
def test_access_denial_fails_immediately(self):
109+
result, calls = self.poll({'list-pipeline-executions': {'error': 'AccessDeniedException'}})
110+
self.assertNotEqual(result.returncode, 0)
111+
self.assertIn('AccessDeniedException', result.stderr)
112+
self.assertEqual(len(calls.splitlines()), 1)
113+
114+
def test_credentials_expiring_during_target_poll_fail(self):
115+
result, _ = self.poll({'get-deployment-target:target-two': {'error': 'ExpiredToken'}})
116+
self.assertNotEqual(result.returncode, 0)
117+
self.assertIn('ExpiredToken', result.stderr)
118+
119+
def test_failed_and_superseded_pipeline_never_reach_deployment(self):
120+
for status in ('Failed', 'Stopped', 'Superseded'):
121+
with self.subTest(status=status):
122+
result, calls = self.poll({'get-pipeline-execution': {'text': status}})
123+
self.assertNotEqual(result.returncode, 0)
124+
self.assertNotIn('get-deployment ', calls)
125+
126+
def test_waits_for_queued_deploy_action(self):
127+
result, calls = self.poll({'list-action-executions': [{'text': 'None'}, {'text': 'd-current'}]})
128+
self.assertEqual(result.returncode, 0, result.stderr)
129+
self.assertEqual(calls.count('list-action-executions '), 2)
130+
131+
def test_failed_deployment_never_accepts_old_cutover(self):
132+
result, calls = self.poll({'get-deployment': {'text': 'Failed'}})
133+
self.assertNotEqual(result.returncode, 0)
134+
self.assertNotIn('get-deployment-target ', calls)
135+
136+
def test_empty_targets_cannot_satisfy_gate(self):
137+
result, _ = self.poll({'list-deployment-targets': {'text': ''}})
138+
self.assertNotEqual(result.returncode, 0)
139+
self.assertIn('timed out', result.stdout)
140+
141+
def test_failed_target_fails_immediately(self):
142+
result, _ = self.poll({'get-deployment-target:target-two': {'text': 'Failed'}})
143+
self.assertNotEqual(result.returncode, 0)
144+
self.assertIn('cutover status Failed', result.stdout)
145+
146+
def test_unchanged_image_verifies_existing_cutover(self):
147+
result, calls = self.poll({'list-pipeline-executions': {'json': [execution(start=900)]}}, since='0')
148+
self.assertEqual(result.returncode, 0, result.stderr)
149+
self.assertIn('get-deployment-target ', calls)
150+
151+
def test_unchanged_image_rejects_latest_different_deploy(self):
152+
result, calls = self.poll({'list-pipeline-executions': {'json': [
153+
execution(start=900), execution(start=999, digest=OTHER_DIGEST)]}}, since='0')
154+
self.assertNotEqual(result.returncode, 0)
155+
self.assertIn('cutover is unverified', result.stderr)
156+
self.assertNotIn('get-deployment ', calls)
157+
158+
def test_unchanged_image_rejects_failed_previous_deploy(self):
159+
result, _ = self.poll({'get-deployment': {'text': 'Failed'}}, since='0')
160+
self.assertNotEqual(result.returncode, 0)
161+
162+
def test_invalid_metadata_fails_before_aws(self):
163+
result, calls = self.poll(since='corrupted')
164+
self.assertNotEqual(result.returncode, 0)
165+
self.assertEqual(calls, '')
166+
167+
def test_ecr_digest_and_missing_tag(self):
168+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy'], {
169+
'batch-get-image': {'json': {'images': [{'imageId': {'imageDigest': DIGEST}}], 'failures': []}}})
170+
self.assertEqual(result.returncode, 0, result.stderr)
171+
self.assertEqual(result.stdout.strip(), DIGEST)
172+
missing = {'batch-get-image': {'json': {'images': [], 'failures': [{'failureCode': 'ImageNotFound'}]}}}
173+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy', '--allow-missing'], missing)
174+
self.assertEqual(result.returncode, 0, result.stderr)
175+
self.assertEqual(result.stdout.strip(), '')
176+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy'], missing)
177+
self.assertNotEqual(result.returncode, 0)
178+
179+
def test_ecr_response_failures_are_not_missing_images(self):
180+
for response in ({'error': 'AccessDeniedException'}, {'json': {'images': [], 'failures': [{'failureCode': 'KmsError'}]}}, {'json': {'images': [], 'failures': []}}):
181+
with self.subTest(response=response):
182+
result, _ = self.run_script('get-ecr-image-digest.sh', ['app', 'deploy', '--allow-missing'], {'batch-get-image': response})
183+
self.assertNotEqual(result.returncode, 0)
184+
185+
186+
if __name__ == '__main__':
187+
unittest.main()

0 commit comments

Comments
 (0)