2026-09-15 10:17:50 +01:00
|
|
|
"""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
|
2026-09-15 16:01:59 +01:00
|
|
|
from urllib.error import HTTPError, URLError
|
2026-09-15 10:17:50 +01:00
|
|
|
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):
|
2026-09-15 16:01:59 +01:00
|
|
|
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)
|
2026-09-15 10:17:50 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-15 16:01:59 +01:00
|
|
|
last_observation = 'No response received'
|
|
|
|
|
print(f'Waiting for deployed release {sha} / {build_id}', flush=True)
|
2026-09-15 10:17:50 +01:00
|
|
|
while time.monotonic() < deadline:
|
|
|
|
|
try:
|
|
|
|
|
req = Request(f'{base_url.rstrip("/")}/release.json?check={time.time_ns()}',
|
2026-09-15 16:01:59 +01:00
|
|
|
headers={'Cache-Control': 'no-cache',
|
|
|
|
|
'User-Agent': 'SchoolCompare-Release-Check/1.0',
|
|
|
|
|
'Accept': 'application/json'})
|
2026-09-15 10:17:50 +01:00
|
|
|
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
|
2026-09-15 16:01:59 +01:00
|
|
|
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
|
2026-09-15 10:17:50 +01:00
|
|
|
time.sleep(min(5, max(0, deadline - time.monotonic())))
|
2026-09-15 16:01:59 +01:00
|
|
|
raise RuntimeError('Deployment did not report the expected frontend/backend release '
|
|
|
|
|
f'{sha} / {build_id}. Last observation: {last_observation}')
|
2026-09-15 10:17:50 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|