fix(deploy): 统一更新来源配置并校验完整产品发布记录

This commit is contained in:
2026-10-08 19:41:29 +08:00
parent 89a4ccd7e7
commit cfcb88e469
3 changed files with 140 additions and 0 deletions
+5
View File
@@ -77,6 +77,7 @@ services:
rabbitmq: rabbitmq:
condition: service_started condition: service_started
volumes: volumes:
- ./system-update/release-manifest.json:/etc/easyai-updater/release-manifest.json:ro
- ./data/upload:/app/upload - ./data/upload:/app/upload
- ./data/backups:/app/backups - ./data/backups:/app/backups
- ./data/restores:/app/restores - ./data/restores:/app/restores
@@ -145,6 +146,10 @@ services:
- SYSTEM_UPDATE_MODE=${SYSTEM_UPDATE_MODE:-legacy_watchtower} - SYSTEM_UPDATE_MODE=${SYSTEM_UPDATE_MODE:-legacy_watchtower}
- SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015} - SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015}
- SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN} - SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN}
# Same published release source for legacy Watchtower and managed updater.
- UPDATE_MANIFEST_SOURCE=${UPDATE_MANIFEST_SOURCE:-remote}
- UPDATE_MANIFEST_URL=${UPDATE_MANIFEST_URL:-}
- UPDATE_MANIFEST_FILE=/etc/easyai-updater/release-manifest.json
- EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1} - EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1}
# 独立转换 Worker 与 API 共用内部令牌,由安装/升级脚本自动生成。 # 独立转换 Worker 与 API 共用内部令牌,由安装/升级脚本自动生成。
- MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN} - MODEL_CONVERSION_WORKER_TOKEN=${MODEL_CONVERSION_WORKER_TOKEN}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Prepare one published release record from already pushed immutable images."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
from datetime import datetime, timezone
IMAGES = {
'server-main': 'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server',
'ws-gateway': 'registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway',
'web': 'registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai',
}
POLICY = ('managedUpdateAllowed', 'scheduledUpdateAllowed', 'rollbackSafe',
'minimumUpdaterVersion', 'minimumDeploymentSchemaVersion', 'requiredFreeBytes')
def inspect_json(reference, template, run=subprocess.check_output):
args = ['docker', 'buildx', 'imagetools', 'inspect', reference]
args += ['--format', template] if template else ['--raw']
return json.loads(run(args, text=True, timeout=90))
def resolve_component(key, reference, version=None):
image = IMAGES[key]
if not reference.startswith(image + ':') and not reference.startswith(image + '@sha256:'):
raise ValueError('Unexpected image for ' + key)
manifest = inspect_json(reference, None)
# Pin a platform manifest; refuse mixed platform indexes rather than guessing a digest.
if manifest.get('manifests'):
matching = [m for m in manifest['manifests'] if m.get('platform', {}).get('os') == 'linux'
and m.get('platform', {}).get('architecture') == 'amd64']
if len(matching) != 1:
raise ValueError('Missing unique linux/amd64 image for ' + key)
digest = matching[0]['digest']
else:
# The formatted descriptor is resolved by buildx, not a local cached image ID.
descriptor = inspect_json(reference, '{{json .Manifest}}')
digest = descriptor['digest']
pinned = image + '@' + digest
metadata = inspect_json(pinned, '{{json .Image}}')
if metadata.get('os') != 'linux' or metadata.get('architecture') != 'amd64':
raise ValueError('Invalid platform for ' + key)
labels = metadata.get('config', {}).get('Labels', {})
if version and labels.get('org.opencontainers.image.version') != version:
raise ValueError('Published image version differs from release: ' + key)
resolved_manifest = inspect_json(pinned, None)
return {'key': key, 'image': image, 'digest': digest,
'buildId': labels.get('org.opencontainers.image.revision', 'retained-formal-image'),
'imageSizeBytes': sum(layer['size'] for layer in resolved_manifest['layers'])}
def prepare(version, policy, components, now=None):
if not re.fullmatch(r'\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?', version):
raise ValueError('Invalid product version')
if {c['key'] for c in components} != set(IMAGES) or len(components) != 3:
raise ValueError('A release requires all three components')
for key in POLICY[:3]:
if type(policy.get(key)) is not bool:
raise ValueError('Explicit update policy required: ' + key)
identity = hashlib.sha256(json.dumps(components, sort_keys=True).encode()).hexdigest()[:12]
return {'schemaVersion': 1, 'channel': 'stable', 'releaseId': version + '-' + identity,
'productVersion': version, 'platform': 'linux/amd64',
'publishedAt': now or datetime.now(timezone.utc).isoformat(),
**{key: policy[key] for key in POLICY if key in policy}, 'components': components}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--version', required=True)
parser.add_argument('--policy', type=Path, required=True,
help='Reviewed release policy; never infer rollback safety from image labels')
parser.add_argument('--gateway-ref', help='Retain the last released gateway digest when unchanged')
parser.add_argument('--output', type=Path, required=True)
args = parser.parse_args()
policy = json.loads(args.policy.read_text())
gateway = args.gateway_ref
if not gateway:
retained = next(c for c in policy['components'] if c['key'] == 'ws-gateway')
gateway = retained['image'] + '@' + retained['digest']
components = [resolve_component('server-main', IMAGES['server-main'] + ':' + args.version, args.version),
resolve_component('ws-gateway', gateway),
resolve_component('web', IMAGES['web'] + ':' + args.version, args.version)]
release = prepare(args.version, policy, components)
args.output.parent.mkdir(parents=True, exist_ok=True)
# No partial or one-component release becomes visible if inspection fails.
handle, name = tempfile.mkstemp(dir=args.output.parent, prefix='.release-')
try:
with os.fdopen(handle, 'w') as stream:
json.dump(release, stream, indent=2)
stream.write('\n')
os.replace(name, args.output)
finally:
if os.path.exists(name):
os.unlink(name)
print(json.dumps({'productVersion': release['productVersion'], 'releaseId': release['releaseId'],
'output': str(args.output), 'published': False}))
if __name__ == '__main__':
main()
+30
View File
@@ -0,0 +1,30 @@
# 在线更新发布来源
`release-manifest.json` 是唯一的正式产品发布记录。页面检测、任务目标、updater 执行以及没有 updater 的旧 Watchtower 部署均读取它。`APP_VERSION` 与 `/app/version` 只表示当前运行版本,用于展示和更新结果确认;`APP_VERSION_CHECK_BASE_URL` 不再参与新版检测。
默认从部署仓库 main 的发布清单读取,无需先升级某台公开 API 服务。自建发布渠道使用 `UPDATE_MANIFEST_URL`;离线部署明确设置 `UPDATE_MANIFEST_SOURCE=local`,后端和 updater 读取同一个挂载文件。读取失败时显示无法检查更新,不使用过期文件代替正式版本。
发布时先完成后端和网页的同一版本镜像推送,然后生成整套记录:
```sh
python3 scripts/prepare-product-release.py --version 3.24.0.20261008.1 \
--policy system-update/release-manifest.json \
--output system-update/release-manifest.json
```
版本只从镜像实际发布信息读取,不另行人工同步网页版本或 API 运行版本。工具验证服务器和网页镜像的版本标签、linux/amd64 平台、三组件完整性及远程 digest,全部通过后才原子写入。网关未变化时保留上一正式 digest;有变化时显式传入 `--gateway-ref`。发布策略由 `--policy` 指定,必须在发布审查中确认,工具不推断新版本的回滚安全性。生成文件不等于已经发布,提交并发布该记录后才向部署开放新版。
旧 Watchtower 仍执行其原有可变镜像标签,不能按清单锁定 digest。因此发布前须确保其使用的服务器/网页标签与清单版本一致。任务严格检查核心服务恢复和目标运行版本,不会把请求收到或版本不一致当作升级成功。
## 更新状态
- 提交前未能连接更新服务:尚未提交,提示管理员检查容器、地址和授权。单凭连接错误不能断言容器已经退出。
- 旧服务在线、没有历史/进度接口:可执行更新;请求发出后明确提示可能仍在下载或安装。
- 已发送但没有收到回复:保留原任务和请求标识,只查询,不自动再次提交。
- 执行结束但核心服务未恢复、或版本不一致:说明具体未确认项,不显示成功。
进度窗口始终可以关闭,关闭不取消服务端任务。仍在前台查询时可以通过“查看更新进度”重新打开;刷新按原任务恢复。旧 Watchtower 前台等待最多五分钟,之后保留“结果待确认”,服务端继续检查原任务。受管更新有独立的较长执行窗口,但窗口同样随时可关闭。
历史旧任务没有发布标识、目标来自旧运行版本接口时,只在请求已发送、所有核心服务可用且实际安装版本与正式发布记录完全相等时修正结果,保留原目标供核对。带发布标识的新任务和受管任务仍严格匹配原目标,不能用任意更高版本冒充成功。
旧浏览器保存的兼容记录没有任务接口时,同样重新核对当前运行版本和正式发布信息;只有两者完全一致且高于旧目标,才确认新版已安装。无法核对时保留待确认,不接受任意更高版本。