diff --git a/README.md b/README.md index 68632ec..89a9702 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,17 @@ UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' ./update.sh Linux 旧部署如果因本地 Compose 修改无法拉取仓库,可在检查差异后设置 `UPDATE_ADOPT_LEGACY_CONFIG=1`。脚本需要 Python 3 和 PyYAML;先校验合并配置,再将原始配置和冲突文件备份到 `data/deployment-backups/`,本地差异保存在 `data/deployment-config/legacy-compose.json`,仓库继续管理新增服务和配置。其他业务代码修改、未支持的文件冲突或无法表示的删除会阻止迁移,不启动容器。活动 Nginx 配置不会被改写。 +迁移默认保留本地固定镜像。如果已确认某个核心服务的旧 digest 应恢复为部署仓库的镜像定义,可显式指定该服务;只允许本次更新范围中的 `easyai-server`、`easyai-web` 或 `ws-gateway`,其他镜像和本地配置继续保留: + +```bash +UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \ +UPDATE_SERVICES='easyai-server easyai-web' \ +UPDATE_ADOPT_LEGACY_CONFIG=1 \ +UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web' ./update.sh +``` + +该操作由脚本备份并迁移本地 Compose 差异,随后快进部署仓库;不需要直接编辑生产 Compose。执行前须检查所有有效 Compose 文件;如果已存在覆盖文件固定同一服务的镜像,不能仅恢复主文件就宣称已解除固定。更新后须检查有效配置中的镜像及容器实际 digest。 + 没有自更新能力的历史脚本可先通过 Git 获取新版 `update.sh` 和 `scripts/` 到临时目录,再以 `UPDATE_PROJECT_DIR=/实际/easyai/目录` 执行新版脚本;配置迁移、仓库更新和容器操作仍由 `update.sh` 完成。已有 managed updater 应使用仓库分配的独立内网地址,避免在主后端暂时停止时占用其固定地址。 - **查看帮助**:`./update.sh -h` 或 `./update.sh --help` diff --git a/scripts/prepare-update-repository.py b/scripts/prepare-update-repository.py index c1393d6..dea341f 100644 --- a/scripts/prepare-update-repository.py +++ b/scripts/prepare-update-repository.py @@ -30,6 +30,10 @@ def git(*args): 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('❌ 存在部署配置以外的本地代码修改;尚未修改配置或容器') @@ -125,6 +129,14 @@ 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 @@ -180,7 +192,9 @@ 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, 'files': sorted(changed | collisions)}, indent=2)) +(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(changed | collisions)}, indent=2)) (backup / 'migration.json').chmod(0o600) print('✅ 已备份旧配置并迁移本地差异;新版部署字段由仓库继续管理') print(' 备份目录: ' + str(backup)) +if restore_images: + print(' 已恢复仓库镜像定义: ' + ', '.join(sorted(restore_images))) diff --git a/scripts/test-model-conversion-update.py b/scripts/test-model-conversion-update.py index b7b417a..a53df85 100644 --- a/scripts/test-model-conversion-update.py +++ b/scripts/test-model-conversion-update.py @@ -14,6 +14,7 @@ import time parser = argparse.ArgumentParser() parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parent.parent) +parser.add_argument('--bash-only', action='store_true', help='Validate Linux updates only; the default still requires Bash and PowerShell.') options = parser.parse_args() source = options.source.resolve() started = time.monotonic() @@ -88,6 +89,8 @@ def assert_selected(root, command_file, output): with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporary: base = Path(temporary) for name, command in [('bash', ['bash', 'update.sh']), ('powershell', ['pwsh', '-NoProfile', '-File', 'update.ps1'])]: + if options.bash_only and name != 'bash': + continue case = base / name case.mkdir() root, log, env = fixture(case) @@ -138,15 +141,26 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar with (seed / 'update.sh').open('a') as stream: stream.write('\n# Updated deployment fixture\n') for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]: assert run(args, seed).returncode == 0 - result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full'}) + compose_path = root / 'docker-compose.yml' + compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${VERSION}', 'comfy-ai/one-ai@sha256:' + 'a' * 64)) + result = run(['bash', 'update.sh'], root, {**env, 'UPDATE_MODE': 'full', 'UPDATE_ADOPT_LEGACY_CONFIG': '1', 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'}) assert result.returncode == 0, result.stdout + result.stderr assert '切换到新版更新脚本' in result.stdout assert_selected(root, log, result.stdout) assert run(['git', 'rev-parse', 'HEAD'], root).stdout == run(['git', 'rev-parse', 'HEAD'], seed).stdout - print('bash: real Git pull and self re-exec preserve targeted update passed') + assert compose_path.read_text() == (seed / 'docker-compose.yml').read_text() + backups = list((root / 'data/deployment-backups').glob('legacy-update-*')) + assert len(backups) == 1 + assert json.loads((backups[0] / 'migration.json').read_text())['restoredRepositoryImages'] == ['easyai-web'] + print('bash: real Git pull, reviewed image restore and self re-exec preserve targeted update passed') # Resolve the real deployment Compose separately from the entry-point transport fixtures. -resolved = run(['docker', 'compose', '--env-file', '.env.sample', 'config', '--format', 'json'], source) +with tempfile.TemporaryDirectory(prefix='easyai-compose-resolution-') as directory: + compose_root = Path(directory) + shutil.copy2(source / 'docker-compose.yml', compose_root / 'docker-compose.yml') + for sample in source.glob('.env*.sample'): + shutil.copy2(sample, compose_root / sample.name.removesuffix('.sample')) + resolved = run(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], compose_root) assert resolved.returncode == 0, resolved.stderr config = json.loads(resolved.stdout) worker = config['services']['model-conversion-worker'] diff --git a/scripts/test-repository-image-restore.py b/scripts/test-repository-image-restore.py new file mode 100644 index 0000000..e5085e9 --- /dev/null +++ b/scripts/test-repository-image-restore.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Exercise reviewed image-pin adoption with real Git and Compose parsing only.""" +import json +import os +from pathlib import Path +import subprocess +import tempfile +import time + +source = Path(__file__).resolve().parent.parent +prepare = Path(os.environ.get('EASYAI_PREPARE_UPDATE_SCRIPT', source / 'scripts/prepare-update-repository.py')) +started = time.monotonic() +pin = 'repository-web@sha256:' + 'a' * 64 + + +def run(args, root, env=None): + return subprocess.run(args, cwd=root, env=env, capture_output=True, text=True, timeout=30) + + +def checked(args, root, env=None): + result = run(args, root, env) + assert result.returncode == 0, result.stdout + result.stderr + return result.stdout + + +for restore in ('', 'easyai-web', 'redis', 'ws-gateway'): + with tempfile.TemporaryDirectory(prefix='easyai-image-restore-') as directory: + base = Path(directory) + root = base / 'easyai' + root.mkdir() + compose = '''services: + easyai-web: + image: repository-web:${VERSION} + environment: + - EXISTING=keep + easyai-server: + image: repository-server:${VERSION} + environment: + - LOCAL_SETTING=original + redis: + image: redis:7 +''' + (root / 'docker-compose.yml').write_text(compose) + (root / '.gitignore').write_text('.env\ndata/\n') + env_original = b'VERSION=latest\nCONFIG_JWT_SECRET=test-private-value\n' + (root / '.env').write_bytes(env_original) + for args in (['git', 'init', '-q', '-b', 'main'], ['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid'], ['git', 'add', '.'], ['git', 'commit', '-qm', 'test: 旧部署基准']): + checked(args, root) + remote = base / 'remote.git' + seed = base / 'seed' + checked(['git', 'clone', '-q', '--bare', str(root), str(remote)], base) + checked(['git', 'remote', 'add', 'origin', str(remote)], root) + checked(['git', 'fetch', '-q'], root) + checked(['git', 'branch', '--set-upstream-to', 'origin/main'], root) + checked(['git', 'clone', '-q', str(remote), str(seed)], base) + (seed / 'docker-compose.yml').write_text(compose.replace(' - EXISTING=keep', ' - EXISTING=keep\n - NEW_SETTING=from-repository')) + for args in (['git', 'config', 'user.name', 'Update test'], ['git', 'config', 'user.email', 'test@example.invalid'], ['git', 'add', '.'], ['git', 'commit', '-qm', 'test: 新部署配置'], ['git', 'push', '-q']): + checked(args, seed) + original = compose.replace('repository-web:${VERSION}', pin).replace('LOCAL_SETTING=original', 'LOCAL_SETTING=custom').replace('redis:7', 'redis:6.2') + (root / 'docker-compose.yml').write_text(original) + env = {**os.environ, 'UPDATE_RESTORE_REPOSITORY_IMAGES': restore, 'UPDATE_SERVICES': 'easyai-server easyai-web'} + result = run(['python3', str(prepare), str(root)], root, env) + if restore in ('redis', 'ws-gateway'): + assert result.returncode != 0, result.stdout + assert (root / 'docker-compose.yml').read_text() == original + assert (root / '.env').read_bytes() == env_original + assert not (root / 'data').exists() + continue + assert result.returncode == 0, result.stdout + result.stderr + checked(['git', 'pull', '--ff-only'], root) + config = json.loads(checked(['docker', 'compose', '--env-file', '.env', 'config', '--format', 'json'], root)) + services = config['services'] + assert services['easyai-web']['image'] == ('repository-web:latest' if restore else pin), services['easyai-web']['image'] + assert services['easyai-web']['environment']['NEW_SETTING'] == 'from-repository' + assert services['easyai-server']['environment']['LOCAL_SETTING'] == 'custom' + assert services['redis']['image'] == 'redis:6.2' + assert 'CONFIG_JWT_SECRET=test-private-value' in (root / '.env').read_text() + backups = list((root / 'data/deployment-backups').glob('legacy-update-*')) + assert len(backups) == 1 + assert (backups[0] / 'docker-compose.yml').read_text() == original + assert (backups[0] / '.env').read_bytes() == env_original + assert (backups[0] / '.env').stat().st_mode & 0o077 == 0 + metadata = json.loads((backups[0] / 'migration.json').read_text()) + if restore: + assert metadata['restoredRepositoryImages'] == ['easyai-web'] + # A second adoption preserves the result and creates no extra backup. + checked(['python3', str(prepare), str(root)], root, env) + assert len(list((root / 'data/deployment-backups').glob('legacy-update-*'))) == 1 + +for mode, adopt in (('image', '1'), ('full', '0')): + env = {**os.environ, 'UPDATE_MODE': mode, 'UPDATE_ADOPT_LEGACY_CONFIG': adopt, 'UPDATE_RESTORE_REPOSITORY_IMAGES': 'easyai-web'} + result = run(['bash', str(source / 'update.sh')], source, env) + assert result.returncode != 0 and '尚未修改配置或容器' in result.stderr + +print(json.dumps({'passed': True, 'cases': 6, 'durationSeconds': round(time.monotonic() - started, 3)})) diff --git a/update.sh b/update.sh index 912c70a..00ade56 100755 --- a/update.sh +++ b/update.sh @@ -23,6 +23,8 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then echo " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。" echo " UPDATE_ADOPT_LEGACY_CONFIG=1" echo " 备份旧部署配置并将本地差异迁移为独立覆盖文件。" + echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'" + echo " 迁移旧配置时恢复指定核心服务的仓库镜像定义;必须属于 UPDATE_SERVICES。" echo "" echo "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。" exit 0 @@ -97,6 +99,11 @@ resolve_update_mode() { resolve_update_mode +if [ -n "${UPDATE_RESTORE_REPOSITORY_IMAGES:-}" ] && { [ "$SKIP_REPO_UPDATE" = true ] || [ "${UPDATE_ADOPT_LEGACY_CONFIG:-0}" != "1" ]; }; then + echo "❌ 恢复仓库镜像必须使用仓库更新模式和 UPDATE_ADOPT_LEGACY_CONFIG=1;尚未修改配置或容器" >&2 + exit 1 +fi + if [ -n "${UPDATE_RELEASE_VERSION:-}" ] && ! [[ "$UPDATE_RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?$ ]]; then echo "❌ UPDATE_RELEASE_VERSION 格式不正确;尚未修改配置或容器" >&2 exit 1 @@ -139,7 +146,7 @@ if [ "$SKIP_REPO_UPDATE" = false ]; then after_revision="$(git rev-parse HEAD)" if [ "$before_revision" != "$after_revision" ] && [ "${EASYAI_UPDATE_REEXECUTED:-0}" != "1" ]; then echo "🔄 检测到部署仓库已更新,切换到新版更新脚本继续执行..." - exec env EASYAI_UPDATE_REEXECUTED=1 UPDATE_SKIP_REPO_UPDATE=true "$SCRIPT_DIR/update.sh" + exec env EASYAI_UPDATE_REEXECUTED=1 UPDATE_SKIP_REPO_UPDATE=true UPDATE_RESTORE_REPOSITORY_IMAGES= "$SCRIPT_DIR/update.sh" fi echo ""