232 lines
11 KiB
Python
232 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Adopt reviewed legacy deployment edits into a local Compose override.
|
|
|
|
Only deployment configuration is accepted. Business code and arbitrary untracked
|
|
files remain untouched. Backups contain private settings and use mode 0700/0600.
|
|
"""
|
|
import copy
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
sys.exit('❌ 迁移旧部署配置需要 Python PyYAML;尚未修改配置或容器')
|
|
|
|
root = Path(sys.argv[1]).resolve()
|
|
os.chdir(root)
|
|
|
|
|
|
def git(*args):
|
|
return subprocess.check_output(['git', *args], text=True)
|
|
|
|
|
|
upstream = git('rev-parse', '--abbrev-ref', '@{upstream}').strip()
|
|
subprocess.run(['git', 'fetch', '--quiet', upstream.split('/')[0]], check=True)
|
|
subprocess.run(['git', 'merge-base', '--is-ancestor', 'HEAD', upstream], check=True)
|
|
restore_images = set(os.environ.get('UPDATE_RESTORE_REPOSITORY_IMAGES', '').split())
|
|
selected_services = set(os.environ.get('UPDATE_SERVICES', '').split())
|
|
if restore_images - {'easyai-server', 'easyai-web', 'ws-gateway'} or restore_images - selected_services:
|
|
sys.exit('❌ 恢复仓库镜像只支持本次 UPDATE_SERVICES 中的核心服务;尚未修改配置或容器')
|
|
changed = set(git('diff', 'HEAD', '--name-only').splitlines())
|
|
if changed - {'docker-compose.yml'}:
|
|
sys.exit('❌ 存在部署配置以外的本地代码修改;尚未修改配置或容器')
|
|
tracked = set(git('ls-tree', '-r', '--name-only', upstream).splitlines())
|
|
untracked = set(git('ls-files', '--others', '--exclude-standard').splitlines())
|
|
collisions = tracked & untracked
|
|
allowed = {'scripts/init-security-env.sh'}
|
|
if any(p not in allowed and not (Path(p).parent == Path('.') and p.endswith('.conf')) for p in collisions):
|
|
sys.exit('❌ 存在新版仓库会覆盖的其他本地文件;尚未修改配置或容器')
|
|
if not changed and not collisions:
|
|
existing_override_path = root / 'data/deployment-config/legacy-compose.json'
|
|
restore_existing_pin = False
|
|
if restore_images and existing_override_path.exists():
|
|
if existing_override_path.is_symlink() or not existing_override_path.is_file():
|
|
sys.exit('❌ 旧配置覆盖文件类型不受支持;尚未修改配置或容器')
|
|
existing_override = json.loads(existing_override_path.read_text())
|
|
restore_existing_pin = any('image' in existing_override.get('services', {}).get(service, {}) for service in restore_images)
|
|
if not restore_existing_pin:
|
|
sys.exit(0)
|
|
for name in changed | collisions:
|
|
if not (root / name).is_file() or (root / name).is_symlink():
|
|
sys.exit('❌ 配置文件类型不受支持;尚未修改配置或容器')
|
|
|
|
|
|
MISSING = object()
|
|
|
|
|
|
def clone(value):
|
|
return MISSING if value is MISSING else copy.deepcopy(value)
|
|
|
|
|
|
def volume_target(value):
|
|
# Ignore colons inside ${VARIABLE:-default} when identifying the mount target.
|
|
depth = 0
|
|
separators = []
|
|
for i, char in enumerate(value):
|
|
if char == '{': depth += 1
|
|
elif char == '}': depth -= 1
|
|
elif char == ':' and depth == 0: separators.append(i)
|
|
if not separators: return value
|
|
end = separators[1] if len(separators) > 1 else len(value)
|
|
return value[separators[0] + 1:end]
|
|
|
|
|
|
def canonical(value, key=''):
|
|
if isinstance(value, dict):
|
|
return {k: canonical(v, k) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
if key in ('environment', 'labels'):
|
|
return dict((v.partition('=')[0], v.partition('=')[2] if '=' in v else None) for v in value)
|
|
if key == 'networks': return {v: {} for v in value}
|
|
if key == 'volumes' and all(isinstance(v, str) for v in value):
|
|
return {volume_target(v): v for v in value}
|
|
return value
|
|
|
|
|
|
preserved = []
|
|
|
|
|
|
def merge(base, local, remote, path=()):
|
|
if local == base: return clone(remote)
|
|
if local == remote: return clone(remote)
|
|
if isinstance(local, dict) and isinstance(remote, dict) and (isinstance(base, dict) or base is MISSING):
|
|
old = base if isinstance(base, dict) else {}
|
|
result = {}
|
|
for key in sorted(set(old) | set(local) | set(remote)):
|
|
value = merge(old.get(key, MISSING), local.get(key, MISSING), remote.get(key, MISSING), path + (key,))
|
|
if value is not MISSING: result[key] = value
|
|
return result
|
|
if local is MISSING and base is MISSING: return clone(remote)
|
|
if local is MISSING: return MISSING
|
|
if remote is MISSING and base is not MISSING:
|
|
sys.exit('❌ 新版删除了仍被本地修改的字段,无法安全合并;尚未修改配置或容器')
|
|
preserved.append('.'.join(path))
|
|
return clone(local)
|
|
|
|
|
|
def delta(remote, merged, path=()):
|
|
if remote == merged: return MISSING
|
|
if isinstance(remote, dict) and isinstance(merged, dict):
|
|
result = {}
|
|
for key in merged:
|
|
value = delta(remote.get(key, MISSING), merged[key], path + (key,))
|
|
if value is not MISSING: result[key] = value
|
|
# Removal cannot be safely represented by an ordinary Compose override.
|
|
if set(remote) - set(merged):
|
|
fields = ', '.join('.'.join(path + (key,)) for key in sorted(set(remote) - set(merged)))
|
|
sys.exit('❌ 本地配置删除了新版字段,无法安全合并;尚未修改配置或容器: ' + fields)
|
|
return result
|
|
return copy.deepcopy(merged)
|
|
|
|
|
|
def serialize(value, key=''):
|
|
if isinstance(value, dict):
|
|
if key == 'volumes' and value and all(isinstance(v, str) for v in value.values()): return list(value.values())
|
|
return {k: serialize(v, k) for k, v in value.items()}
|
|
return value
|
|
|
|
|
|
base = canonical(yaml.safe_load(git('show', 'HEAD:docker-compose.yml')))
|
|
local = canonical(yaml.safe_load((root / 'docker-compose.yml').read_text()))
|
|
remote_text = git('show', upstream + ':docker-compose.yml')
|
|
remote = canonical(yaml.safe_load(remote_text))
|
|
# Explicitly release reviewed local image pins through the deployment project.
|
|
# Other local settings and pins on services outside this update remain intact.
|
|
for service in sorted(restore_images):
|
|
remote_service = remote.get('services', {}).get(service, {})
|
|
local_service = local.get('services', {}).get(service, {})
|
|
if not isinstance(remote_service.get('image'), str) or not isinstance(local_service, dict) or not local_service:
|
|
sys.exit('❌ 无法读取目标服务的仓库镜像定义;尚未修改配置或容器')
|
|
local_service['image'] = remote_service['image']
|
|
# Early managed deployments removed Watchtower's old token while disabling its
|
|
# HTTP trigger. Current deployments require a shared internal token; restore the
|
|
# upstream reference, whose value is initialized by update.sh, rather than
|
|
# preserving an unauthenticated monitor. Other removals still stop adoption.
|
|
monitor = local.get('services', {}).get('watchtower', {}).get('environment', {})
|
|
old_monitor = base.get('services', {}).get('watchtower', {}).get('environment', {})
|
|
new_monitor = remote.get('services', {}).get('watchtower', {}).get('environment', {})
|
|
token_key = 'WATCHTOWER_HTTP_API_TOKEN'
|
|
if token_key in old_monitor and token_key not in monitor and token_key in new_monitor:
|
|
monitor = local['services']['watchtower'].setdefault('environment', {})
|
|
monitor[token_key] = new_monitor[token_key]
|
|
merged = merge(base, local, remote)
|
|
override = delta(remote, merged)
|
|
config_dir = root / 'data/deployment-config'
|
|
override_path = config_dir / 'legacy-compose.json'
|
|
|
|
def apply_override(original, additional):
|
|
if isinstance(original, dict) and isinstance(additional, dict):
|
|
result = copy.deepcopy(original)
|
|
for key, value in additional.items():
|
|
result[key] = apply_override(result.get(key, MISSING), value)
|
|
return result
|
|
return copy.deepcopy(additional)
|
|
|
|
# Repeated adoption must preserve already migrated settings. A newly reviewed
|
|
# main-file difference has the same precedence as the old main file; only
|
|
# explicitly restored target images are released back to the repository.
|
|
if override_path.exists():
|
|
if override_path.is_symlink() or not override_path.is_file():
|
|
sys.exit('❌ 旧配置覆盖文件类型不受支持;尚未修改配置或容器')
|
|
previous_override = canonical(json.loads(override_path.read_text()))
|
|
for service in restore_images:
|
|
previous_override.get('services', {}).get(service, {}).pop('image', None)
|
|
override = apply_override(previous_override, {} if override is MISSING else override)
|
|
merged = apply_override(remote, override)
|
|
|
|
with tempfile.NamedTemporaryFile(mode='w', dir=root, prefix='.update-compose-', suffix='.json', delete=False) as candidate:
|
|
json.dump(serialize(merged), candidate)
|
|
candidate_path = Path(candidate.name)
|
|
try:
|
|
candidate_path.chmod(0o600)
|
|
compose = ['docker', 'compose'] if shutil.which('docker') else ['docker-compose']
|
|
validation = subprocess.run(compose + ['--env-file', '.env', '-f', str(candidate_path), 'config', '--quiet'], capture_output=True, text=True)
|
|
if validation.returncode:
|
|
sys.exit('❌ 合并后的部署配置未通过校验;尚未修改配置或容器')
|
|
finally:
|
|
candidate_path.unlink()
|
|
backup_root = root / 'data/deployment-backups'
|
|
backup_root.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
backup = Path(tempfile.mkdtemp(prefix='legacy-update-' + time.strftime('%Y%m%d-%H%M%S') + '-', dir=backup_root))
|
|
backup.chmod(0o700)
|
|
backup_files = changed | collisions | {'.env'}
|
|
if override_path.exists(): backup_files.add(str(override_path.relative_to(root)))
|
|
for name in backup_files:
|
|
destination = backup / name
|
|
destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
shutil.copy2(root / name, destination)
|
|
destination.chmod(0o600)
|
|
config_dir = root / 'data/deployment-config'
|
|
config_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
override_path = config_dir / 'legacy-compose.json'
|
|
if override is not MISSING and override:
|
|
override_path.write_text(json.dumps(serialize(override), indent=2) + '\n')
|
|
override_path.chmod(0o600)
|
|
env_path = root / '.env'
|
|
lines = env_path.read_text().splitlines()
|
|
existing = next((line.partition('=')[2].strip().strip('"').strip("'") for line in lines if line.startswith('COMPOSE_FILE=')), '')
|
|
files = existing.split(os.pathsep) if existing else ['docker-compose.yml']
|
|
relative = str(override_path.relative_to(root))
|
|
if relative not in files: files.append(relative)
|
|
lines = [line for line in lines if not line.startswith('COMPOSE_FILE=')]
|
|
lines.append('COMPOSE_FILE=' + os.pathsep.join(files))
|
|
env_path.write_text('\n'.join(lines) + '\n')
|
|
env_path.chmod(0o600)
|
|
for name in collisions:
|
|
(root / name).unlink()
|
|
if changed:
|
|
subprocess.run(['git', 'restore', '--source=HEAD', '--staged', '--worktree', '--', 'docker-compose.yml'], check=True)
|
|
(backup / 'migration.json').write_text(json.dumps({'from': git('rev-parse', 'HEAD').strip(), 'to': git('rev-parse', upstream).strip(), 'preservedFields': preserved, 'restoredRepositoryImages': sorted(restore_images), 'files': sorted(backup_files)}, indent=2))
|
|
(backup / 'migration.json').chmod(0o600)
|
|
print('✅ 已备份旧配置并迁移本地差异;新版部署字段由仓库继续管理')
|
|
print(' 备份目录: ' + str(backup))
|
|
if restore_images:
|
|
print(' 已恢复仓库镜像定义: ' + ', '.join(sorted(restore_images)))
|