The staging poller swallowed every failure identically, so a run that timed out told us only that the expected release never appeared — not whether the proxy refused us, the endpoint was down, or the containers were still serving an older build. The public staging proxy also answers 403 to urllib's default user agent while the release endpoint is healthy, which looked exactly like a deployment that never arrived. Identify the poller, and report each distinct observation once: HTTP status, connection failure type, invalid JSON, or the release identities actually reported. The timeout error carries the last observation and the identity it wanted. Responses and the base URL stay out of the logs — only validated sha/build_id fields are echoed back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
175 lines
7.0 KiB
Python
175 lines
7.0 KiB
Python
"""Release identity checks and promotion of the exact digests that passed E2E.
|
|
|
|
Uses only the standard library and Docker Buildx. No registry mutation happens
|
|
until every image in the set has been resolved and its build labels validated.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.request import Request, urlopen
|
|
|
|
COMPONENTS = ('BACKEND', 'FRONTEND', 'PIPELINE')
|
|
|
|
|
|
def docker(*args):
|
|
return subprocess.check_output(['docker', 'buildx', 'imagetools', *args], text=True).strip()
|
|
|
|
|
|
def check_sha(sha):
|
|
if not re.fullmatch(r'[0-9a-f]{40}', sha):
|
|
raise ValueError('Expected a full commit SHA')
|
|
return sha
|
|
|
|
|
|
def check_digest(digest):
|
|
if not re.fullmatch(r'sha256:[0-9a-f]{64}', digest):
|
|
raise ValueError('Expected an immutable image digest')
|
|
return digest
|
|
|
|
|
|
def image_identity(ref):
|
|
image = json.loads(docker('inspect', ref, '--format', '{{json .Image}}'))
|
|
configs = [image] if 'config' in image else list(image.values())
|
|
identities = set()
|
|
for config in configs:
|
|
labels = config['config']['Labels']
|
|
identities.add((labels['io.schoolcompare.commit'], labels['io.schoolcompare.build-id']))
|
|
if len(identities) != 1:
|
|
raise ValueError('Image platforms disagree about their release identity')
|
|
return next(iter(identities))
|
|
|
|
|
|
def resolve_images(sha, verified=False, expected_build_id=None):
|
|
check_sha(sha)
|
|
refs = []
|
|
build_ids = set()
|
|
for component in COMPONENTS:
|
|
image = f"{os.environ['REGISTRY']}/{os.environ[component + '_IMAGE_NAME']}"
|
|
if verified:
|
|
manifest = json.loads(docker('inspect', f'{image}:verified-{sha}', '--format', '{{json .Manifest}}'))
|
|
digest = check_digest(manifest['digest'])
|
|
else:
|
|
digest = check_digest(os.environ[component + '_DIGEST'])
|
|
ref = f'{image}@{digest}'
|
|
actual_sha, build_id = image_identity(ref)
|
|
if actual_sha != sha or not re.fullmatch(r'[0-9a-f]{32}', build_id):
|
|
raise ValueError(f'Unrecognised release identity for {component}')
|
|
if expected_build_id is not None and build_id != expected_build_id:
|
|
raise ValueError(f'Build identity mismatch for {component}')
|
|
build_ids.add(build_id)
|
|
refs.append((image, ref))
|
|
if len(build_ids) != 1:
|
|
raise ValueError('Refusing a mixed image set')
|
|
return {'sha': sha, 'build_id': build_ids.pop(), 'images': refs}
|
|
|
|
|
|
def verify(sha, build_id):
|
|
release = resolve_images(sha, expected_build_id=build_id)
|
|
for image, ref in release['images']:
|
|
docker('create', '-t', f'{image}:verified-{sha}', ref)
|
|
return release
|
|
|
|
|
|
def promote(sha):
|
|
release = resolve_images(sha, verified=True)
|
|
# Resolve all targets first; never discover a missing candidate halfway through.
|
|
for image, _ in release['images']:
|
|
try:
|
|
docker('create', '-t', f'{image}:prod-previous', f'{image}:prod')
|
|
except subprocess.CalledProcessError:
|
|
print(f'No rollback pointer saved for {image}', flush=True)
|
|
for image, ref in release['images']:
|
|
docker('create', '-t', f'{image}:prod', ref)
|
|
return release
|
|
|
|
|
|
def matches(payload, sha, build_id):
|
|
return isinstance(payload, dict) and all(
|
|
payload.get(component) == {'sha': sha, 'build_id': build_id}
|
|
for component in ('frontend', 'backend'))
|
|
|
|
|
|
def describe_identity(payload):
|
|
"""Log only release fields, never arbitrary response bodies or secret URLs."""
|
|
if not isinstance(payload, dict):
|
|
return 'Invalid release response: expected a JSON object'
|
|
identities = []
|
|
for component in ('frontend', 'backend'):
|
|
identity = payload.get(component)
|
|
if not isinstance(identity, dict):
|
|
identities.append(f'{component}=missing or invalid')
|
|
continue
|
|
values = []
|
|
for field, length in (('sha', 40), ('build_id', 32)):
|
|
value = identity.get(field)
|
|
valid = isinstance(value, str) and (
|
|
value == 'development' or re.fullmatch(r'[0-9a-f]{' + str(length) + '}', value))
|
|
values.append(f'{field}={value if valid else "missing or invalid"}')
|
|
identities.append(f'{component}: {", ".join(values)}')
|
|
return 'Release mismatch: ' + '; '.join(identities)
|
|
|
|
|
|
def wait(base_url, sha, build_id, timeout):
|
|
check_sha(sha)
|
|
if not re.fullmatch(r'[0-9a-f]{32}', build_id):
|
|
raise ValueError('Missing expected build identity')
|
|
deadline = time.monotonic() + timeout
|
|
last_observation = 'No response received'
|
|
print(f'Waiting for deployed release {sha} / {build_id}', flush=True)
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
req = Request(f'{base_url.rstrip("/")}/release.json?check={time.time_ns()}',
|
|
headers={'Cache-Control': 'no-cache',
|
|
'User-Agent': 'SchoolCompare-Release-Check/1.0',
|
|
'Accept': 'application/json'})
|
|
with urlopen(req, timeout=min(10, max(.1, deadline - time.monotonic()))) as response:
|
|
payload = json.load(response)
|
|
if matches(payload, sha, build_id):
|
|
print(f'Verified deployed release {sha} / {build_id}')
|
|
return
|
|
observation = describe_identity(payload)
|
|
except HTTPError as exc:
|
|
observation = f'Release endpoint returned HTTP {exc.code}'
|
|
exc.close()
|
|
except URLError as exc:
|
|
observation = f'Release endpoint connection failed ({type(exc.reason).__name__})'
|
|
except OSError as exc:
|
|
observation = f'Release endpoint request failed ({type(exc).__name__})'
|
|
except ValueError:
|
|
observation = 'Release endpoint returned invalid JSON or request configuration'
|
|
if observation != last_observation:
|
|
print(observation, flush=True)
|
|
last_observation = observation
|
|
time.sleep(min(5, max(0, deadline - time.monotonic())))
|
|
raise RuntimeError('Deployment did not report the expected frontend/backend release '
|
|
f'{sha} / {build_id}. Last observation: {last_observation}')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('action', choices=['wait', 'verify', 'promote'])
|
|
parser.add_argument('--timeout', type=float, default=300)
|
|
parser.add_argument('--release', type=Path)
|
|
parser.add_argument('--output', type=Path)
|
|
args = parser.parse_args()
|
|
identity = json.loads(args.release.read_text()) if args.release else {
|
|
'sha': os.environ.get('EXPECTED_SHA', ''),
|
|
'build_id': os.environ.get('EXPECTED_BUILD_ID', ''),
|
|
}
|
|
if args.action == 'wait':
|
|
wait(os.environ['BASE_URL'], identity['sha'], identity['build_id'], args.timeout)
|
|
return
|
|
result = (verify(identity['sha'], identity['build_id']) if args.action == 'verify'
|
|
else promote(identity['sha']))
|
|
if args.output:
|
|
args.output.write_text(json.dumps(result))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|