#!/usr/bin/env python3 """Explicitly adopt an old non-Git Linux package IN PLACE, preserving its data/config. The source must be a clean Git checkout of the reviewed deployment repository. Unknown/custom configuration is preserved in an ignored Compose override. """ import argparse from datetime import datetime, timezone import hashlib import json import os from pathlib import Path import shutil import subprocess import tempfile parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('project', type=Path) parser.add_argument('source', type=Path) args = parser.parse_args() project, source = args.project.resolve(), args.source.resolve() def git(root, *arguments): return subprocess.check_output(['git', '-C', str(root), *arguments], text=True).strip() def main(): try: import yaml except ImportError: raise SystemExit('旧部署包迁移需要 PyYAML;请先安装 python3-yaml,尚未修改部署目录') if (project / '.git').exists() or project == source: raise SystemExit('此入口只用于没有 Git 的旧部署包') env = project / '.env' compose = project / 'docker-compose.yml' if not env.is_file() or not compose.is_file() or env.is_symlink() or compose.is_symlink(): raise SystemExit('旧目录缺少 .env 或 docker-compose.yml,不能自动迁移') content = env.read_text() if any(line.startswith('COMPOSE_FILE=') and line.split('=', 1)[1].strip().strip('\"\'') for line in content.splitlines()): raise SystemExit('旧目录已有 COMPOSE_FILE 覆盖,请先核对差异后迁移;未修改配置') if git(source, 'status', '--porcelain', '--untracked-files=no'): raise SystemExit('迁移源含未提交改动,请使用已提交的部署仓库') revision = git(source, 'rev-parse', 'HEAD') repository = git(source, 'remote', 'get-url', 'origin') try: legacy = yaml.safe_load(compose.read_text()) except (yaml.YAMLError, UnicodeError): raise SystemExit('旧 Compose 无法解析,尚未修改配置或容器') if not isinstance(legacy, dict) or not {'easyai-server', 'easyai-web'} <= set(legacy.get('services', {})): raise SystemExit('不能识别旧 Compose 服务,未修改部署目录') for service in legacy['services'].values(): image = service.get('image', '') if image.startswith(('registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:', 'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:', 'registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:', 'registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:', 'registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:', 'registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:', 'registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:')): service.pop('image') legacy.pop('version', None) paths = git(source, 'ls-tree', '-r', '--name-only', 'HEAD').splitlines() for name in paths: target = project / name if target.is_symlink() or os.path.commonpath([str(target.resolve()), str(project)]) != str(project): raise SystemExit('目标路径包含符号链接或越界路径,未修改部署目录') if target.exists() and not target.is_file(): raise SystemExit('目标文件与已有目录冲突,未修改部署目录') if name.startswith(('data/', 'sandbox-data/')) and target.is_file() and target.read_bytes() != (source / name).read_bytes(): raise SystemExit('旧数据目录含有与仓库模板不同的文件,请先核对差异;未覆盖原数据') # Ordinary Compose overrides APPEND port bindings. Validate the effective # merge before adopting it; never silently open an extra default port. with tempfile.TemporaryDirectory(prefix='easyai-package-check-') as temporary: staging = Path(temporary) for name in paths: target = staging / name target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source / name, target) for sample in source.glob('.env*.sample'): shutil.copyfile(sample, staging / sample.name[:-len('.sample')]) for private in project.glob('.env*'): if private.is_file() and not private.name.endswith('.sample'): shutil.copyfile(private, staging / private.name) shutil.copyfile(compose, staging / 'old-package.yml') (staging / 'legacy-package.json').write_text(json.dumps(legacy)) compose_command = ['docker', 'compose'] if subprocess.run(compose_command + ['version'], capture_output=True).returncode: compose_command = ['docker-compose'] def configuration(files): command = compose_command + ['--project-name', 'easyai-package-check', '--project-directory', str(staging), '--env-file', str(staging / '.env')] for name in files: command += ['-f', str(staging / name)] result = subprocess.run(command + ['config', '--format', 'json'], capture_output=True, text=True) if result.returncode: raise SystemExit('旧配置无法与新版安全合并;请检查环境文件和 Compose 版本,原部署未修改') return json.loads(result.stdout) original = configuration(['old-package.yml']) merged = configuration(['docker-compose.yml', 'legacy-package.json']) for service, settings in original['services'].items(): old_ports = settings.get('ports', []) new_ports = merged['services'][service].get('ports', []) if old_ports and old_ports != new_ports: raise SystemExit(f'服务 {service} 的旧端口无法安全表示为覆盖配置;请先核对差异,原部署未修改') os.umask(0o077) backups = project / 'data/deployment-backups' backups.mkdir(parents=True, exist_ok=True) backup = Path(tempfile.mkdtemp(prefix='package-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=backups)) records = [] # Include all files the new repository will replace, plus private settings. for name in sorted(set(paths) | {p.name for p in project.glob('.env*') if p.is_file()}): original = project / name if original.is_file(): saved = backup / 'files' / name saved.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(original, saved) saved.chmod(0o600) records.append({'file': name, 'sha256': hashlib.sha256(saved.read_bytes()).hexdigest()}) (backup / 'backup.json').write_text(json.dumps({'kind': 'old-package-config-only', 'targetCommit': revision, 'files': records}, indent=2) + '\n') override = project / 'data/deployment-config/legacy-package.json' override.parent.mkdir(parents=True, exist_ok=True) override.write_text(json.dumps(legacy, ensure_ascii=False, indent=2) + '\n') # Create Git metadata without checking out over private settings or data. git(project, 'init', '-q') git(project, 'remote', 'add', 'origin', repository) git(project, 'fetch', '-q', '--depth=1', str(source), 'HEAD') git(project, 'symbolic-ref', 'HEAD', 'refs/heads/main') git(project, 'update-ref', 'refs/heads/main', revision) git(project, 'read-tree', 'HEAD') git(project, 'config', 'branch.main.remote', 'origin') git(project, 'config', 'branch.main.merge', 'refs/heads/main') for name in paths: destination = project / name if name.startswith(('data/', 'sandbox-data/')) and destination.exists(): continue destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source / name, destination) with (project / '.git/info/exclude').open('a') as output: output.write('\n/data/deployment-config/\n/data/deployment-backups/\n') if content and not content.endswith('\n'): content += '\n' content += 'COMPOSE_FILE=docker-compose.yml:data/deployment-config/legacy-package.json\n' env.write_text(content) env.chmod(0o600) print('已在原目录接入部署仓库;保留原 .env、数据目录、数据卷与本地配置。') print('旧文件备份(不含数据库):' + str(backup)) if __name__ == '__main__': main()