153 lines
8.3 KiB
Python
153 lines
8.3 KiB
Python
#!/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()
|