|
- """Promote the three validated v2 bundles without replacing platform identities."""
- import importlib.util
- import json
- import shlex
- import shutil
- import subprocess
- from pathlib import Path
-
- import paramiko
- from retrain_instructors_v2 import ROOT, REPORT, BUNDLES, IDS, FILES, api_session, API, HTTP, SERVICE, save
-
- RELEASE = '/home/digital-human-service/releases/instructors-v2-20260914'
- HOST_SOURCE = '/home/ai_person/ai_person_service/builtin_avatars'
- CONTAINER = 'digital-human-service'
-
-
- def connect():
- spec = importlib.util.spec_from_file_location('connection', ROOT / 'ai_person/ai_person_api/.run/test_file_service_tunnel.py')
- module = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(module)
- host, port, user, password = module._read_connection()
- client = paramiko.SSHClient()
- client.load_system_host_keys()
- client.set_missing_host_key_policy(paramiko.RejectPolicy())
- client.connect(host, port=port, username=user, password=password, look_for_keys=False, allow_agent=False)
- return client
-
-
- def run(client, command):
- _, stdout, stderr = client.exec_command(command, timeout=300)
- output, error = stdout.read().decode('utf-8', 'replace'), stderr.read().decode('utf-8', 'replace')
- if stdout.channel.recv_exit_status() != 0:
- raise RuntimeError(output + error)
- return output.strip()
-
-
- def remote_promote(client, jobs, checks):
- # Use the service's own metadata operation. No SQL is issued against the platform database.
- script = r'''
- import hashlib,json,shutil
- from pathlib import Path
- from app.config import settings
- from app import database
- rows=PAYLOAD
- backup=settings.data_root/'backups'/'instructors-v2-20260914'
- if backup.exists():raise RuntimeError('Backup exists; refuse a second promotion')
- source=settings.project_root/'builtin_avatars'
- catalog={x['id']:x for x in json.loads((source/'catalog.json').read_text())}
- for row in rows:
- assert row['stable_id'] in catalog
- assets=settings.avatars_root/row['avatar_id']/'assets'
- assert database.get_avatar(row['avatar_id'])['status']=='ready'
- for name,digest in row['sha256'].items():assert hashlib.sha256((assets/name).read_bytes()).hexdigest()==digest
- backup.mkdir(parents=True)
- before=[]
- for row in rows:
- ident=row['stable_id']
- before.append(database.get_avatar(ident))
- shutil.copytree(source/ident,backup/'builtin'/ident)
- shutil.copytree(settings.avatars_root/ident,backup/'runtime'/ident)
- (backup/'before-metadata.json').write_text(json.dumps(before,ensure_ascii=False,indent=2))
- for row in rows:
- ident=row['stable_id'];assets=settings.avatars_root/row['avatar_id']/'assets'
- for root in (source,settings.avatars_root):
- stage=root/(ident+'.v2-stage')
- if stage.exists():raise RuntimeError('Unexpected stale staging directory')
- shutil.copytree(root/ident,stage)
- for name in row['sha256']:shutil.copy2(assets/name,stage/'assets'/name)
- previous=root/(ident+'.v1-previous')
- if previous.exists():raise RuntimeError('Unexpected previous directory')
- (root/ident).rename(previous)
- try:stage.rename(root/ident)
- except BaseException:previous.rename(root/ident);raise
- # Keep the renamed directories until all replacement checks finish.
- manifest=json.loads((assets/'manifest.json').read_text())
- spec=catalog[ident]
- database.upsert_builtin_avatar(avatar_id=ident,name=spec['name'],source_filename='built_in:'+spec['code'],
- frame_count=manifest['frame_count'],width=manifest['width'],height=manifest['height'],duration=manifest['duration'])
- print(json.dumps({'backup':str(backup),'updated':[row['stable_id'] for row in rows]}))
- '''.replace('PAYLOAD', repr([{**job, 'sha256': check['sha256']} for job, check in zip(jobs, checks)]))
- return run(client, 'docker exec -w /app ' + CONTAINER + ' python -c ' + shlex.quote(script))
-
-
- def main():
- if (REPORT / 'promotion.json').exists():
- raise RuntimeError('Already promoted; do not run twice')
- jobs = json.loads((REPORT / 'jobs.json').read_text('utf-8'))
- checks = json.loads((REPORT / 'generated-validation.json').read_text('utf-8'))
- playback = json.loads((REPORT / 'generated-viewer-validation.json').read_text('utf-8'))
- assert len(jobs) == len(checks) == len(playback) == 3
- assert all(not row['errors'] and row['driver']['active'] > 30 for row in playback)
- client = connect()
- try:
- base_image = run(client, "docker inspect digital-human-service --format '{{.Image}}'")
- run(client, f'mkdir -p {RELEASE}/backup-host {RELEASE}/builtin_avatars')
- for ident in IDS:
- run(client, f'test -d {HOST_SOURCE}/{ident} && cp -a {HOST_SOURCE}/{ident} {RELEASE}/backup-host/{ident}')
- save('promotion-start.json', {'baseImage': base_image, 'remoteRelease': RELEASE})
- result = json.loads(remote_promote(client, jobs, checks))
- save('promotion-runtime.json', result)
- for ident in IDS:
- run(client, f'docker cp {CONTAINER}:/app/builtin_avatars/{ident} {RELEASE}/builtin_avatars/{ident}')
- for filename in FILES:
- run(client, f'cp {RELEASE}/builtin_avatars/{ident}/assets/{filename} {HOST_SOURCE}/{ident}/assets/{filename}')
- shutil.copy2(REPORT / 'generated' / ident / 'assets' / filename, BUNDLES / ident / 'assets' / filename)
- # Persist bundles in the deployment image as well as the running container.
- # The base is the exact current image; no application source is upgraded.
- dockerfile = f'FROM {base_image}\nCOPY builtin_avatars/ /app/builtin_avatars/\nLABEL ai-person.avatar-material-version="instructors-v2-20260914"\n'
- with client.open_sftp() as sftp:
- with sftp.open(RELEASE + '/Dockerfile', 'w') as stream:
- stream.write(dockerfile)
- run(client, f'docker tag {base_image} digital-human-service:before-instructors-v2-20260914')
- output = run(client, f'docker build --network=none -t digital-human-service:instructors-v2-20260914 {RELEASE}')
- (REPORT / 'image-build.log').write_text(output, encoding='utf-8')
- run(client, 'docker tag digital-human-service:instructors-v2-20260914 digital-human-service:cpu')
- image_id = run(client, "docker image inspect digital-human-service:cpu --format '{{.Id}}'")
- save('promotion.json', {**result, 'oldImage': base_image, 'newImage': image_id, 'release': RELEASE})
- print(json.dumps({'updated': IDS, 'newImage': image_id, 'backup': result['backup']}), flush=True)
- finally:
- client.close()
-
-
- if __name__ == '__main__':
- main()
|