fix(deploy): 通过更新脚本恢复受控仓库镜像定义
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)}))
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user