Files
easyai/scripts/prepare-product-release.py

106 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Prepare one published release record from already pushed immutable images."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
from datetime import datetime, timezone
IMAGES = {
'server-main': 'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server',
'ws-gateway': 'registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway',
'web': 'registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai',
}
POLICY = ('managedUpdateAllowed', 'scheduledUpdateAllowed', 'rollbackSafe',
'minimumUpdaterVersion', 'minimumDeploymentSchemaVersion', 'requiredFreeBytes')
def inspect_json(reference, template, run=subprocess.check_output):
args = ['docker', 'buildx', 'imagetools', 'inspect', reference]
args += ['--format', template] if template else ['--raw']
return json.loads(run(args, text=True, timeout=90))
def resolve_component(key, reference, version=None):
image = IMAGES[key]
if not reference.startswith(image + ':') and not reference.startswith(image + '@sha256:'):
raise ValueError('Unexpected image for ' + key)
manifest = inspect_json(reference, None)
# Pin a platform manifest; refuse mixed platform indexes rather than guessing a digest.
if manifest.get('manifests'):
matching = [m for m in manifest['manifests'] if m.get('platform', {}).get('os') == 'linux'
and m.get('platform', {}).get('architecture') == 'amd64']
if len(matching) != 1:
raise ValueError('Missing unique linux/amd64 image for ' + key)
digest = matching[0]['digest']
else:
# The formatted descriptor is resolved by buildx, not a local cached image ID.
descriptor = inspect_json(reference, '{{json .Manifest}}')
digest = descriptor['digest']
pinned = image + '@' + digest
metadata = inspect_json(pinned, '{{json .Image}}')
if metadata.get('os') != 'linux' or metadata.get('architecture') != 'amd64':
raise ValueError('Invalid platform for ' + key)
labels = metadata.get('config', {}).get('Labels', {})
if version and labels.get('org.opencontainers.image.version') != version:
raise ValueError('Published image version differs from release: ' + key)
resolved_manifest = inspect_json(pinned, None)
return {'key': key, 'image': image, 'digest': digest,
'buildId': labels.get('org.opencontainers.image.revision', 'retained-formal-image'),
'imageSizeBytes': sum(layer['size'] for layer in resolved_manifest['layers'])}
def prepare(version, policy, components, now=None):
if not re.fullmatch(r'\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?', version):
raise ValueError('Invalid product version')
if {c['key'] for c in components} != set(IMAGES) or len(components) != 3:
raise ValueError('A release requires all three components')
for key in POLICY[:3]:
if type(policy.get(key)) is not bool:
raise ValueError('Explicit update policy required: ' + key)
identity = hashlib.sha256(json.dumps(components, sort_keys=True).encode()).hexdigest()[:12]
return {'schemaVersion': 1, 'channel': 'stable', 'releaseId': version + '-' + identity,
'productVersion': version, 'platform': 'linux/amd64',
'publishedAt': now or datetime.now(timezone.utc).isoformat(),
**{key: policy[key] for key in POLICY if key in policy}, 'components': components}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--version', required=True)
parser.add_argument('--policy', type=Path, required=True,
help='Reviewed release policy; never infer rollback safety from image labels')
parser.add_argument('--gateway-ref', help='Retain the last released gateway digest when unchanged')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
policy = json.loads(args.policy.read_text())
gateway = args.gateway_ref
if not gateway:
retained = next(c for c in policy['components'] if c['key'] == 'ws-gateway')
gateway = retained['image'] + '@' + retained['digest']
components = [resolve_component('server-main', IMAGES['server-main'] + ':' + args.version, args.version),
resolve_component('ws-gateway', gateway),
resolve_component('web', IMAGES['web'] + ':' + args.version, args.version)]
release = prepare(args.version, policy, components)
args.output.parent.mkdir(parents=True, exist_ok=True)
# No partial or one-component release becomes visible if inspection fails.
handle, name = tempfile.mkstemp(dir=args.output.parent, prefix='.release-')
try:
with os.fdopen(handle, 'w') as stream:
json.dump(release, stream, indent=2)
stream.write('\n')
os.replace(name, args.output)
finally:
if os.path.exists(name):
os.unlink(name)
print(json.dumps({'productVersion': release['productVersion'], 'releaseId': release['releaseId'],
'output': str(args.output), 'published': False}))
if __name__ == '__main__':
main()