|
| 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