Files
school_compare/scripts/ci/release.py
T

174 lines
7.0 KiB
Python
Raw Normal View History

"""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()