fix(deploy): 通过更新脚本恢复受控仓库镜像定义
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s

This commit is contained in:
easyai02
2026-10-05 11:35:47 +08:00
parent 125b12a1d8
commit e3d760c5ab
5 changed files with 146 additions and 5 deletions
+11
View File
@@ -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`
+15 -1
View File
@@ -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)))
+17 -3
View File
@@ -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']
+95
View File
@@ -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)}))
+8 -1
View File
@@ -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 ""