#!/usr/bin/env python3 """One-time, scoped cutover from the loopback preview; preserve a recovery snapshot.""" import argparse import hashlib import json from pathlib import Path import subprocess import tarfile from start import CONTAINER, IMAGE, validate_runtime OLD = 'adamwen-test-console-before-lan-20261013' def run(args): return subprocess.check_output(args, text=True, timeout=90).strip() def inspect(name): result = subprocess.run(['docker','inspect',name], capture_output=True, text=True) return json.loads(result.stdout)[0] if result.returncode == 0 else None def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--project-root',type=Path,required=True) args = parser.parse_args() project = args.project_root.resolve(strict=True) runtime = validate_runtime(project) before = inspect(CONTAINER) if before is None or before['Config'].get('Labels',{}).get('cn.aisuan.owner') != CONTAINER: parser.error('Expected the existing project-owned preview container') if before['HostConfig'].get('PortBindings',{}).get('4440/tcp') != [{'HostIp':'127.0.0.1','HostPort':'14440'}]: parser.error('Expected the original loopback preview; do not repeat this migration') if inspect(OLD) or inspect('adamwen-test-console-proxy'): parser.error('Cutover containers already exist; inspect the saved outcome before retrying') if inspect(IMAGE) is None: parser.error('Build the new project image before cutover') backup = runtime / 'backups/before-lan-20261013' if not (backup/'source/deploy/start.py').is_file() or backup.is_symlink(): parser.error('The source snapshot must already exist inside this project') archive = backup/'runtime-before-lan.tar.gz' if archive.exists(): parser.error('A data snapshot already exists; refusing to overwrite it') active = run(['docker','exec',CONTAINER,'python3','-c', "from pathlib import Path;import os;print(len([p for p in Path('/proc').glob('[0-9]*/cmdline') if p.parent.name != str(os.getpid()) and any(x in p.read_bytes().split(b'\\x00') for x in (b'/opt/xas-test-library/scripts/run_smoke.py',b'/opt/xas-test-library/scripts/smoke_task.py'))]))"]) if active != '0': parser.error('An acceptance task is still running; wait for it to finish') for name in ('configure-login.sh','set_login.py'): current, saved = project/'deploy'/name, backup/'source/deploy'/name if current.exists() and hashlib.sha256(current.read_bytes()).digest() != hashlib.sha256(saved.read_bytes()).digest(): parser.error('A legacy source file has changed since backup; preserve the user change') print(run(['docker','stop','--timeout','30',CONTAINER]),flush=True) try: with tarfile.open(archive,'x:gz') as tar: for name in ('data','logs','projects','results','auth'): source = runtime/name if source.exists(): tar.add(source,arcname=name,recursive=True) archive.chmod(0o600) metadata = {'container_id':before['Id'],'image_id':before['Image'], 'started_at':before['State']['StartedAt'],'backup_container':OLD, 'database_snapshot':archive.name} (backup/'metadata.json').write_text(json.dumps(metadata,indent=2),encoding='utf-8') print(run(['docker','rename',CONTAINER,OLD]),flush=True) except Exception: run(['docker','start',CONTAINER]) raise # The two removed files are this project's reviewed password provisioning implementation. for name in ('configure-login.sh','set_login.py'): path=project/'deploy'/name if path.exists(): path.unlink() result = subprocess.run(['python3',str(project/'deploy/start.py'),'--project-root',str(project)]) if result.returncode: raise SystemExit('New stack was not fully started. Old container and data snapshot are preserved; inspect before rollback.') print(json.dumps({'cutover':'started','backup':str(backup),'previous_container':OLD})) if __name__ == '__main__': main()