106 lines
5.1 KiB
Python
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()
|