36 Commits
Author SHA1 Message Date
easyai03 895d92317e chore(release): 将3.30.2.20261017提升为稳定版 2026-10-17 09:56:57 +08:00
easyai03 be086ab2cc chore(release): 登记 3.30.2.20261017 固定版本产物 2026-10-17 03:20:50 +08:00
easyai03 e15fb6b0f1 chore(release): 将成功结果恢复修复提升为官方稳定版 2026-10-16 11:23:53 +08:00
easyai03 2b15588caf chore(release): 记录成功结果恢复修复的官方固定镜像 2026-10-16 11:09:28 +08:00
easyai03 4df7625052 chore(deploy): 补齐前后端固定镜像测试入口
合入已审查并通过定向验证的剩余改动,保留当前主线与已上线修复。
2026-10-16 09:36:55 +08:00
easyai03 1f12419742 chore(deploy): 集成定向测试入口并同步主线
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
2026-10-16 09:34:44 +08:00
easyai03 24d7efbb1b chore(release): 登记 3.30.0.20261016 固定版本产物 2026-10-16 03:29:42 +08:00
wangbo 9a55fcfe5b chore(release): 合并结果恢复与转存修复发布清单
纳入已验收固定版本及正式发布元数据。
2026-10-15 20:27:13 +08:00
wangbo 8d60d463ec chore(release): 登记结果恢复与转存修复固定版本 2026-10-15 20:25:30 +08:00
wangbo 3641e91346 feat(catalog): 将官方模型目录迁入公开Git仓库 (#11)
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
合入已通过定向验收的官方模型目录 Git 维护与消费迁移。
2026-10-15 11:57:20 +08:00
wangbo 9547a027a2 feat(catalog): 将官方模型目录迁入公开Git仓库
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
2026-10-15 11:53:42 +08:00
easyai03 dad460581e chore(release): 将 3.29.0.20261015 提升为稳定版 2026-10-15 09:56:52 +08:00
easyai03 e78c183888 chore(release): 记录 3.29.0.20261015 固定版本产物 2026-10-15 03:26:33 +08:00
easyai03 dbb6bbce02 fix(release): 按服务核验 Compose 固定镜像并保留线上修复 2026-10-15 03:09:51 +08:00
wangbo 2db18f5804 chore(deploy): 补充前端正式产物的测试验收入口 2026-10-14 12:09:37 +08:00
wangbo ef471e2106 chore(deploy): 提供保留现有配置的后端测试镜像入口 2026-10-14 11:43:55 +08:00
wangbo bf0ac6db54 fix(deploy): 按服务读取镜像定义避免依赖镜像误判 2026-10-14 11:24:13 +08:00
easyai03 9a801ea3d5 fix(release): 指定环境版本并仅人工提升稳定版
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
日常更新现在将已选应用的固定版本写入服务器 .env,通过仓库 Compose 变量解析镜像。Bash/PowerShell 拒绝浮动版本,并在 pull/up 前检查有效镜像与指定 tag 一致;只更新目标服务,核心单独更新保留其他版本,兼容 VERSION 继承值。Compose 变更仅在仓库维护,服务器仍由更新/迁移脚本同步,不手工编辑。

稳定版独立入口必须提供同一个完整 version/confirm-version;所有固定产物 digest/架构先核验,然后按 digest 补 latest/latest-no-ssr,全部读回后才生成稳定清单。失败尝试恢复旧稳定指针。日常版本记录使用 system-update/releases/<版本>.json;补录现有 .14.2 已验收来源,没有改变现有稳定清单。

验证:稳定提升6项通过;真实 Bash/PowerShell入口(本机隔离命令传输)28.845秒通过,含单后端、核心、独立网关/Updater、浮动/覆盖拒绝、真实 Git pull/旧配置迁移及实际 Compose 解析;环境迁移通过,旧部署迁移0.894秒通过。未连接或更新生产/测试服务器,未执行真实镜像构建/标签推送。
2026-10-14 08:40:48 +08:00
easyai03 6691ac34e0 fix(release): 补齐前端变体的人工稳定标签映射
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
2026-10-14 08:39:50 +08:00
easyai03 8c1eadc5ea fix(release): 环境固定版本并拆分人工稳定版提升
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
2026-10-14 08:35:04 +08:00
wangbo 5fa19eff08 Merge pull request 'fix(release): 同步公开搜索组织范围修复稳定清单' (#8) from codex/search-scope-release-20261014 into main
fix(release): 发布公开搜索组织范围修复正式版本
2026-10-14 01:00:11 +08:00
wangbo 30c5d31787 fix(release): 同步公开搜索组织范围修复稳定清单 2026-10-14 00:59:32 +08:00
wangbo e337f6b7fb Merge pull request 'fix(release): 发布历史搜索递归溢出修复正式清单' (#7) from codex/search-recursion-release-20261014 into main
fix(release): 同步历史搜索递归修复正式稳定清单
2026-10-14 00:48:42 +08:00
wangbo 15a36fd085 fix(release): 发布历史搜索递归溢出修复正式清单 2026-10-14 00:47:14 +08:00
wangbo b77f27f6d5 Merge pull request 'chore(release): 发布 3.29.0.20261013.6 段错误修复清单' (#6) from codex/native-stack-release-20261013 into main
chore(release): 合并主后端段错误修复正式发布清单
2026-10-14 00:05:49 +08:00
wangbo 25e38b4441 chore(release): 发布主后端段错误修复正式镜像清单 2026-10-14 00:04:46 +08:00
easyai03 111ccab266 Merge pull request 'chore(release): 发布 3.29.0.20261013.5 镜像清单' (#5) from codex/runninghub-release-20261013-deploy into main 2026-10-13 20:01:07 +08:00
easyai03 23c9a989d4 chore(release): 更新正式镜像清单至 3.29.0.20261013.5 2026-10-13 19:59:14 +08:00
wangbo e082e24be7 Merge pull request 'chore(release): 发布后端性能修复清单并复用前端镜像' (#4) from codex/expense-index-followup-release-20261013 into main 2026-10-13 18:48:22 +08:00
wangbo ede392c310 chore(release): 更新后端性能修复镜像清单并复用前端产物 2026-10-13 18:47:00 +08:00
wangbo 7114eeee01 chore(release): 发布费用列表保护与查询性能修复清单 2026-10-13 13:24:32 +08:00
wangbo 85399bb185 chore(release): 发布费用列表保护与查询性能修复清单 2026-10-13 13:21:31 +08:00
wangbo 79e6bdf65e chore(release): 发布主站索引与媒体上下文修复清单 2026-10-13 10:54:11 +08:00
easyai03 ffaa01d434 chore(release): 发布 3.29.0.20261013 稳定清单并标注凭据回滚边界 2026-10-13 03:25:18 +08:00
easyai03 4cff2dcc58 feat(deploy): 合并凭据加密迁移及密钥备份恢复 (#2)
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
完成 API Key 加密、旧部署兼容及迁移支持。

本地验收通过;宁波服务器验收尚未执行。
任务:credential-ningbo-20261012
2026-10-13 00:37:53 +08:00
easyai03 5cff1041a9 feat(deploy): 补齐凭据加密接入迁移和密钥备份恢复
Test deployment public URL (Linux) / test-linux (push) Canceled after 0s
Test start.ps1 (Windows) / test-windows (push) Canceled after 0s
Test deployment public URL (Linux) / test-linux (pull_request) Canceled after 0s
Test start.ps1 (Windows) / test-windows (pull_request) Canceled after 0s
首次安装默认生成独立加密密钥,更新旧部署保留原配置,通过显式开关进入迁移窗口。增加生产迁移包装入口和完整密钥环备份恢复工具,并说明旧镜像升级、严格迁移、轮换及还原流程。

已通过 Bash 安全环境及密钥备份回归;对应 PowerShell 5.1 和 7 脚本已在 Windows 原生环境各通过 49 项验收。宁波服务器验收尚未执行。
2026-10-12 22:24:16 +08:00
48 changed files with 31897 additions and 64 deletions
+18 -2
View File
@@ -128,6 +128,14 @@ CONFIG_JWT_SECRET=
# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。
CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=
# 独立凭据加密密钥。新安装由脚本生成,重复部署保留;不得复用 JWT 密钥。
# 存量部署首次接入:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true ./update.sh
# 未接入的旧部署保持兼容;已有密文时必须恢复原密钥,禁止重新生成。
CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=
CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=
# 新安装 false;存量接入 true,迁移 verify --finalize=true 后关闭。
CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=
# Canvas CLI / Agent WebSocket 短时票据专用签名密钥。
# 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。
WS_AUTH_WS_TICKET_SECRET=
@@ -251,7 +259,15 @@ STATIC_CDN_OSS_USE_SSL=true
STATIC_CDN_FORCE_UPLOAD=false
# ========== 15. 版本 ==========
VERSION=latest
VERSION=3.29.0.20261014.2
# 应用镜像只使用完整版本号;latest/latest-no-ssr 仅在人工指定版本后提升为稳定版。
# 组件未单独填写时继承 VERSION;update.sh/update.ps1 会持久化实际选中的组件版本。
SERVER_VERSION=
WEB_VERSION=
WS_GATEWAY_VERSION=
UPDATER_VERSION=
ASG_VERSION=
AMS_VERSION=
# ========== 15.1 在线更新 ==========
# 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。
@@ -286,7 +302,7 @@ EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
# ========== 15.2 3D 模型轻量转换 ==========
# update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。
# 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_WORKER_VERSION=
# 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。
MODEL_CONVERSION_WORKER_TOKEN=
# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。
+12
View File
@@ -0,0 +1,12 @@
.env
# 私有部署密钥、恢复副本与数据库/队列归档
.env.*
*.env
!.env*.sample
.env.pre-credential-restore.*
*.credential-keys.*
data/credential-migration/
data/credential-recovery/
data/backups/
data/restores/
+18 -4
View File
@@ -42,6 +42,10 @@ Linux IP 模式会自动安装并配置 Nginx,浏览器统一访问 `http://IP
生成结果仅保存在本机 `.env`。除首次安装完成页会明确显示登录用的初始管理员密码外,脚本日志对其他安全密钥只显示变量名,不输出密钥内容。不要把 `.env` 提交到 Git;集群部署时,各 `easyai-server` / `ws-gateway` 实例必须使用同一份持久化配置。
#### API Key 加密与旧部署升级
新安装脚本自动生成独立凭据加密密钥;存量部署默认保留兼容模式,未升级脚本的用户只更新兼容镜像也可继续使用原 API Key 和平台凭据。首次接入使用 `DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true` 执行新版更新脚本,完成历史迁移后关闭明文读取;已有密文时丢失密钥会拒绝启动。数据库归档与完整原 keyring、配置主密钥/JWT 分开备份;新版提供密钥导出/还原和覆盖前预检。操作步骤、Windows 入口、备份还原顺序和回滚条件见 [API Key 加密部署与旧用户兼容](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md)。
#### 初始管理员密码生命周期
首次安装时,部署脚本会生成 20 位随机密码,写入 `.env` 的 `CONFIG_INITIAL_ADMIN_PASSWORD`,并在部署完成页直接显示:
@@ -171,6 +175,10 @@ mv easyai easyai2
5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。
日常发布只构建并推送完整版本标签,线上与测试环境均通过 `update.sh` / `update.ps1` 选择指定版本。`UPDATE_RELEASE_VERSION` 统一指定已选组件;`UPDATE_SERVER_VERSION`、`UPDATE_WEB_VERSION`、`UPDATE_GATEWAY_VERSION`、`UPDATE_WORKER_VERSION` 可分别指定已验证版本。脚本持久化 `SERVER_VERSION`、`WEB_VERSION`、`WS_GATEWAY_VERSION`、`MODEL_CONVERSION_WORKER_VERSION` 等到服务器 `.env`,保留非目标的原版本,拉取前核对有效 Compose 确实使用这些固定 tag。配置只能由已提交的部署仓库与迁移脚本更新,不手工修改服务器 `.env` 或 Compose/覆盖文件。
`latest` / `latest-no-ssr` 仅作为人工稳定版指针。只有用户明确指定版本转稳定版后才运行 `node scripts/promote-stable-release.mjs --version <版本> --confirm-version <同一版本>`(先 `--dry-run`),按固定产物 digest 复制稳定别名、读回并生成稳定清单,再审查和提交;日常 deploy 不调用此入口,也不修改稳定清单。该操作不重编业务镜像、不改业务环境版本。版本记录须包括独立 SSR/no-SSR 和实际使用的必要配套产物。
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
@@ -186,7 +194,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
更新执行器 `easyai-updater` 由 Watchtower 默认每 24 小时检查更新,同时由宿主机定时任务每六小时直接检查更新,提供两条独立维护路径。它的任务状态保存在宿主机 `data/update-state` 中,自身更新或意外重启后会继续恢复未完成任务;Watchtower 不再接管三个核心服务,因此页面任务结果不会与后台静默替换相互冲突。
受控更新默认直接获取部署仓库 `main` 分支的 `system-update/release-manifest.json`,无需先在服务器执行 `git pull`。页面检测、手动更新和自动更新使用同一发布清单;任务开始后将清单保存在任务记录中,服务重启恢复时继续使用原目标。发布新正式镜像时必须同步发布清单中的不可变摘要、镜像大小和版本标识,不能只修改官方站运行版本或推送 `latest` 镜像。
受控更新默认直接获取部署仓库 `main` 分支的 `system-update/release-manifest.json`,无需先在服务器执行 `git pull`。页面检测、手动更新和自动更新使用同一发布清单;任务开始后将清单保存在任务记录中,服务重启恢复时继续使用原目标。该清单只表示人工确认的稳定版。日常发布只推完整版本标签并保存 `system-update/releases/<版本>.json`;只有人类明确指定某个版本转稳定版后,才更新稳定清单与 `latest` / `latest-no-ssr`。线上或测试环境的普通版本发布不会自动推广给其他安装。
发布源不可用时会提示暂时无法检查更新,并保留当前服务;不会用历史清单宣称已是最新。`UPDATE_MANIFEST_URL` 可配置私有发布源,留空时使用官方部署仓库。离线部署可显式设置 `UPDATE_MANIFEST_SOURCE=local`,此时读取 Compose 只读挂载的本地清单;需要自行维护该清单。旧部署中的 `UPDATE_MANIFEST_FILE` 路径覆盖不再影响默认远程模式。
@@ -213,7 +221,7 @@ Watchtower 默认使用 `latest`。`start.sh/update.sh` 自动安装宿主机 cr
两个页面入口共用计时和步骤窗口。updater 保存阶段完成时间到 `data/update-state/jobs`;无 updater 时后端保存任务和单任务锁到 MongoDB。刷新或重新登录后按站点与账号恢复任务查询。请求超时或服务断开不会自动创建另一项更新,旧接口无法读取的阶段会明确提示。后端重启时页面保留进度,提醒在 3–5 分钟后刷新检查版本;只有确认目标版本和服务恢复才显示完成。
正式版本检测默认仍使用原来的 `https://openapi.51easyai.com`。测试验收可在后端设置 `APP_VERSION_CHECK_BASE_URL`,该地址应提供与正式地址相同格式的 `/app/version` 响应;正式发布前必须撤销测试覆盖。候选镜像用版本标签发布,验收后才将相同摘要提升为 `latest`。源码、镜像、环境迁移、三种实际更新验收和回退证据记录在 `system-update/releases/`,其中不得包含令牌或登录凭据。
正式版本检测默认仍使用原来的 `https://openapi.51easyai.com`。测试验收可在后端设置 `APP_VERSION_CHECK_BASE_URL`,该地址应提供与正式地址相同格式的 `/app/version` 响应;正式发布前必须撤销测试覆盖。所有日常镜像使用完整版本标签;验收成功也不会自动提升 `latest`,只有人类明确指定版本转稳定版后才按既有摘要提升。源码、镜像、环境迁移、三种实际更新验收和回退证据记录在 `system-update/releases/`,其中不得包含令牌或登录凭据。
以后新增必需环境变量时,在 `scripts/update-env.d/` 增加新的编号 `.env` 文件即可,不需要继续把写入逻辑堆进 `update.sh`。迁移文件支持 `KEY=value`(仅在缺失或空值时写入)、`KEY+=item`(确保逗号列表包含该项),以及 `@skip-if KEY=value`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。
@@ -234,6 +242,7 @@ chmod +x update.sh
- 执行 `./update.sh` 后会**命令行内选择**更新方式:
- `[1]` 更新并拉取仓库(git pull)+ 更新镜像并重启(**默认**,回车即选)
- `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景)
- API Key 加密首次接入需要新版脚本及历史迁移,请按 [加密升级说明](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md) 执行;只有尚未接入加密的旧部署可以继续仅更新兼容镜像。
- 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。
- 自动化更新可通过变量跳过选择:
@@ -252,12 +261,13 @@ UPDATE_SKIP_REPO_UPDATE=true ./update.sh
```bash
UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \
UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' ./update.sh
UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker' \
UPDATE_RELEASE_VERSION="<指定完整版本号>" ./update.sh
```
脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `UPDATE_SERVICES` 时只重建这些服务,不重建数据库等依赖;服务名称有误会在拉取镜像前终止。普通更新不指定该变量时保留原来的整组更新行为。转换容器只加入业务网络,不开放主机端口,不连接沙箱网络,不挂载 Docker socket,也不读取整个生产 `.env`。默认限额为 2 CPU、4 GiB、并发 1、单任务 300 秒、临时空间 2 GiB;原模型始终保留,轻量转换失败可使用原模型。
`MODEL_CONVERSION_WORKER_VERSION` 可以固定已发布的版本;默认 `latest`。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。
`MODEL_CONVERSION_WORKER_VERSION` 使用已发布的固定版本,未独立填写时继承 `VERSION`;更新脚本将目标组件版本持久化到 `.env`,应用更新不接受 `latest` 或其他浮动标签。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。
测试候选版本时使用 `UPDATE_RELEASE_VERSION=3.23.0.20261003`,由更新脚本固定核心服务和转换 Worker 的版本,无需改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。
@@ -736,3 +746,7 @@ apt remove docker-compose
```bash
cd ~/easyai && cp docker-compose.yml docker-compose.yml.backup && wget -q https://git.51easyai.com/wangbo/easyai/raw/main/docker-compose.yml -O docker-compose.yml && echo "✅ docker-compose.yml 更新完成"
```
## 官方模型目录
官方基础模型定义由本仓库独立维护,无需重建后端即可通过 Git 发布。维护源、公开地址、版本准备、校验和回退流程见 [官方模型目录](model-catalog/official/README.md)。平台后端和独立 AI Gateway 只读消费;不再使用原 OSS/CDN 目录发布流程。
+5
View File
@@ -0,0 +1,5 @@
# 自有测试环境的后端候选验收。追加到既有 COMPOSE_FILE,仅选择主后端镜像。
# EASYAI_TEST_SERVER_IMAGE 必须为已发布并核验的完整版本镜像。
services:
easyai-server:
image: ${EASYAI_TEST_SERVER_IMAGE:?EASYAI_TEST_SERVER_IMAGE is required}
+5
View File
@@ -0,0 +1,5 @@
# 自有测试环境与已发布前端产物配对验收,仅选择前端镜像。
# 不参与生产或客户的默认更新配置。
services:
easyai-web:
image: ${EASYAI_TEST_WEB_IMAGE:?EASYAI_TEST_WEB_IMAGE is required}
+9 -7
View File
@@ -5,7 +5,7 @@ services:
easyai-web:
container_name: easyai-web
#更新修改冒号后面的版本号
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION}
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${WEB_VERSION:-${VERSION}}
#端口冲突时,只需要修改前面的端口比如修改为,3011:3010
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
@@ -58,7 +58,7 @@ services:
easyai-server:
container_name: easyai-server
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${SERVER_VERSION:-${VERSION}} # 阿里云
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -88,6 +88,8 @@ services:
environment:
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
# 官方目录由公开 Git 仓库维护,模型数据更新无需重新构建服务镜像。
- CONFIG_OFFICIAL_MODEL_CATALOG_URL=${CONFIG_OFFICIAL_MODEL_CATALOG_URL:-https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json}
# MongoDB配置,用户管理,不会配置的话保持默认即可
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
@@ -170,7 +172,7 @@ services:
max-file: "10"
model-conversion-worker:
container_name: easyai-model-conversion-worker
image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-latest}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-${VERSION}}
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -224,7 +226,7 @@ services:
ws-gateway:
container_name: easyai-wsgateway # websocket网关
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-latest} # 阿里云
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-${VERSION}} # 阿里云
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -391,7 +393,7 @@ services:
max-size: "100m"
max-file: "10"
easyai-updater:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-latest}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-${VERSION}}
container_name: easyai-updater
profiles:
- managed-update
@@ -567,7 +569,7 @@ services:
max-file: "10"
easyai-asg:
container_name: easyai-asg
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-latest}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-${VERSION}}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -599,7 +601,7 @@ services:
memory: 128MB
agent-memory:
container_name: agent-memory
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-latest}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-${VERSION}}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
+135
View File
@@ -0,0 +1,135 @@
# API Key 加密部署与旧用户兼容
用户 API Key、平台认证、云凭据和存储通道支持分阶段加密升级。未接入加密配置且数据库从未启用加密的旧部署,可以保持旧脚本和 Compose 不变,只更新兼容镜像;现有用户 Key 和平台 Key 的值均保留,不需要重新生成或重新填写。旧模式暂时沿用原存储方式,不能把它描述为已完成加密。
## 部署行为
| 场景 | 行为 |
| --- | --- |
| 使用新版启动脚本新安装(原来无 `.env`) | Bash/PowerShell 脚本生成独立随机 32 字节 keyring,持久保存;默认关闭旧明文读取 |
| 旧部署仅更新镜像 | 无新增密钥配置时保持旧读写与鉴权;数据库中有密文或加密启用标记时拒绝缺少密钥的启动 |
| 旧部署执行新版脚本 | 默认保留原模式;显式首次接入后新写入加密、历史明文临时可读,重复执行保留 keyring |
| 完成迁移并重启 | 原用户 Key 按哈希鉴权,平台按原凭据执行;永久关闭旧明文读取 |
数据库保存加密启用标记和密钥验证值。一旦启用,加密密钥丢失、部分配置或错误配置都会报错,不会静默降级或用新的随机密钥替换已有密钥。`verify --finalize=true` 将数据库状态置为严格模式,后续重启即使旧环境仍写着兼容开关 `true`,也不会恢复明文读取。
## 私有配置
| 变量 | 要求 |
| --- | --- |
| `CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID` | 当前写入密钥 ID,字母、数字、下划线或连字符,长度 1 至 64,大小写精确匹配 |
| `CONFIG_CREDENTIAL_ENCRYPTION_KEYRING` | 单行 JSON 对象;值为独立随机 32 字节密钥的标准 Base64;脚本保留已有条目 |
| `CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT` | 新安装 `false`;存量接入阶段 `true`;迁移完成后 `false` |
这组密钥与 `CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY`、JWT 和数据库密码独立,不得复用或派生。密钥只存私有 `.env` 或安全配置中心;Linux/macOS 文件为 0600,Windows 使用当前用户私有 DACL。数据库备份和密钥备份分开保管。更换目录、重新生成配置或恢复备份时,应带回对应的原 keyring。
Compose 会把 `.env` 注入新建容器,修改文件不会改变已经运行的进程环境,因此接入、关闭兼容和轮换后都要重建全部相关实例。网关独立使用 `AI_GATEWAY_CREDENTIAL_*` 配置;其 API、Worker 和迁移进程共享同一组网关密钥,不能复用 server-main 的密钥。
## 存量部署接入与迁移
先将所有读取凭据的应用和 Worker 升级到兼容版本,再允许加密写入。只理解明文的旧进程无法读取密文,混部时不能直接启用加密。以下命令要求镜像包含本次配套实现和 `/app/dist/credential-migrate.js`,不适用于此前的旧镜像。
1. 备份 MongoDB、相关 Redis 队列及私有配置,记录当前应用版本;暂停凭据修改并排空或暂停消费者。
2. 显式接入独立密钥,并重建相关服务。Linux/macOS:
```bash
DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true ./update.sh
```
Windows PowerShell:
```powershell
$env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = 'true'
.\update.ps1
Remove-Item Env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION
```
3. 核对容器内 MongoDB 主机及数据库,按次序执行正式迁移入口。默认 Compose 示例为 `172.21.0.3/aidraw`;自定义连接必须使用实际值。
```bash
bash scripts/migrate-credential-encryption.sh inventory 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh dry-run 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh apply 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh apply 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh verify 172.21.0.3/aidraw
bash scripts/migrate-credential-encryption.sh finalize 172.21.0.3/aidraw
```
PowerShell 使用 `scripts/Migrate-CredentialEncryption.ps1 -Mode inventory -ExpectedTarget '172.21.0.3/aidraw'`,依次替换 Mode 为 `dry-run`、`apply`、`apply`、`verify`、`finalize`。
4. 确认 verify 零待更改、零明文、零冲突;把 `.env` 的 `CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT` 改为 `false`,重建全部相关进程,恢复消费,并用升级前的原用户 Key 验证业务调用。
入口只运行迁移进程,不启动新的应用消费者。正式目标须使用 `--production=true --expected-target=主机/数据库` 精确匹配;工具从服务环境继承认证、数据库名和 Redis 队列配置,不在命令或日志中输出密钥。队列使用配置中的 Redis 主机和 DB,显式覆盖 Redis URL 时还需匹配 `--expected-redis=主机/DB`。
checkpoint 保存在私有 `data/credential-migration` 目录,绑定目标、密钥和轮换设置。旧验收工具的无版本 checkpoint 不能直接复用;换目标或轮换时使用新的 checkpoint。遇到并发冲突会停止,保留 checkpoint、处理冲突后重跑;verify 从头扫描,不会因 checkpoint 跳过残留明文。工具保持 MongoDB 4.4 兼容。
迁移保留用户 Key 的原值、所有权、权限、撤销状态和有效期,增加哈希、密文及摘要。只有哈希的历史 Key 继续鉴权,页面显示无法查看。普通列表不返回完整值;复制或查看由登录会话按需请求。平台凭据按原记录身份加密,编辑摘要会保留原值,复制平台会按新记录身份重新加密。
## 备份、还原与换机
数据库归档只包含数据库中的密文、API Key 哈希、记录身份和 `credential_encryption_state` 验证值,**不会包含部署环境中的解密密钥**。后台的手动/每日自动备份仍可使用,但单独下载数据库文件不足以完成换机恢复。禁止把私有密钥放入上传 OSS 的数据库归档。
需要配套保管:
- 对应时间点的完整 MongoDB 备份,包括主库中的加密状态集合;多库归档包含配置中列出的业务库。
- 完整原 keyring 和 active ID,包括所有仍被历史备份使用的旧密钥。密钥轮换完成不代表旧备份已变为新密钥;保留旧备份期间仍需保留对应旧密钥。
- 原 `CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY` 和 `CONFIG_JWT_SECRET`:已有 MCP、插件、OIDC/JWT 配置使用另一套加密或兼容派生,不能只备份新 API Key 的 keyring。另需私有保存完整 `.env`、相关 `.env.*` 与安全配置中心配置,保留审计、OTP 等原有安全配置和连接参数。
- 需要恢复未完成任务时,同一维护窗口的 Redis 队列备份及相应消费者版本;数据库备份不包含 Redis、PostgreSQL、对象存储或模型文件。
### 独立密钥备份入口
Linux/macOS(输出目录应位于部署目录之外的私有位置):
```bash
mkdir -p "$HOME/.easyai-recovery"
chmod 700 "$HOME/.easyai-recovery"
bash scripts/credential-keys.sh export "$HOME/.easyai-recovery/credential-keys-20261012.env"
```
Windows PowerShell:
```powershell
.\scripts\Manage-CredentialKeys.ps1 -Mode export -Path 'D:\PrivateRecovery\credential-keys-20261012.env'
```
先创建私有输出目录。导出文件保留完整 keyring、兼容开关、配置主密钥和 JWT 原值;文件为 Linux/macOS 0600 或 Windows 当前用户独占 DACL。已有备份不会被覆盖,非法/部分配置和无 keyring 的旧模式不会生成替代密钥。这是**含完整密钥的私有配置文件,不是密码加密的归档**;离机副本放入加密保险库,与数据库归档分开保存。文件及其路径不应提交 Git、发聊天或上传公开存储。
首次接入后立即导出;每次新增或切换密钥后使用新文件再导出。数据库与密钥备份按日期、应用版本和归档指纹配对,并保留旧版本,不能仅留最近一次 active 密钥。旧模式还未生成加密 keyring 时,先保存旧 `.env` 和数据库原备份,接入后补充密钥备份。
### 还原顺序
1. 进入维护窗口,暂停所有写入及消费者,备份当前目标库、队列和私有配置。全库还原使用 `--drop`,会覆盖相应集合,无法与业务写入保证原子性。
2. 在目标目录准备 `.env`,修正地址等连接参数,恢复对应原密钥:
```bash
bash scripts/credential-keys.sh restore "$HOME/.easyai-recovery/credential-keys-20261012.env"
```
```powershell
.\scripts\Manage-CredentialKeys.ps1 -Mode restore -Path 'D:\PrivateRecovery\credential-keys-20261012.env'
```
还原前校验配置,其他环境变量保留;原 `.env` 会保存私有回退副本。恢复入口明确用备份的完整 keyring 替换目标加密配置,适用于目标已误生成另一组随机密钥的情形;其他运行中实例须同步恢复对应配置。旧密钥备份若缺少配置主密钥/JWT 两项,额外恢复原私有 `.env`,不能以新生成值替代。
3. 先重建承接还原的兼容版本服务,让它读取已恢复的环境配置;其他实例和消费者保持暂停,再通过原后台备份记录或上传备份还原。正在运行的进程不会自动读取 `.env` 更改;目标原数据采用另一套密钥而导致服务无法启动时,使用下述离线隔离流程。
4. 后台会先还原至随机命名的临时数据库,检查归档数据库名是否属于已配置业务库,验证全部 API/平台密文、旧 key ID,以及 MCP、插件、OIDC/JWT 加密配置。缺钥、错钥、数据损坏、不安全压缩包或预检失败会拒绝,业务库尚未被覆盖。临时库会保留原索引;加密环境恢复旧明文归档时,先在临时库迁移历史凭据并保持原用户 Key,再导出校验后的归档覆盖业务集合,恢复严格状态。
5. 还原后重建全部相关进程,清理业务缓存;验证原用户 Key 的权限/过期/撤销状态、原平台实际调用、配置查看与复制,再恢复消费。若同时恢复旧 Redis 任务,先运行正式迁移 `apply → verify` 清除历史任务载荷凭据;`verify/finalize` 通过后再重建和消费。
预检需要 Mongo 账户在临时数据库中创建、读写和删除数据的权限,以及临时磁盘空间;权限或空间不足会失败,不会绕过预检。临时库位于同一 Mongo 实例,需约一份数据库工作空间和一份归档空间。macOS 归档附带的 `._*` / `__MACOSX` 资源元数据在解压时忽略。正常完成/失败会清理临时库与校验归档;进程被强制终止时,可能留下 `easyai_credential_restore_*` 临时库,运维确认归属后清理。线上还原仅支持归档内原库名与当前配置匹配;换库名、目标无法启动或跨环境恢复优先走离线隔离流程。
### 无法启动或离线换库恢复
保持业务和消费者停止。在隔离 Mongo 实例/临时目标中用原密钥还原归档,完整保留 `_id`、API Key 哈希和加密状态集合;换库使用 MongoDB Tools 的 `--nsInclude`、`--nsFrom` / `--nsTo` 映射,先核对目标再执行,语法参照 [MongoDB Tools 官方说明](https://www.mongodb.com/docs/database-tools/mongorestore/mongorestore-examples/)。新 API/平台凭据的 AAD 绑定集合、记录身份和字段,未绑定数据库名称;同库结构的换库恢复可保留原值,不能重生成记录 ID 后直接搬密文。
如果归档早于加密接入,在隔离库使用相同原 keyring 和临时兼容开关 `true` 运行正式迁移,再 `verify/finalize`,制作新的已加密备份后恢复目标。`mongorestore --drop` 只删除归档包含的集合,旧归档没有加密状态集合时,直接覆盖已严格化的目标会留下旧标记而混入明文;不要跳过隔离迁移直接手工覆盖。
配置、密钥、数据库和队列确认对应后启动兼容版本,校验正常再切换业务。遗失任何仍在使用的密钥时,不能通过重置 active ID、设兼容开关或生成随机密钥恢复密文,应找回对应私有备份。
## 轮换与回滚
轮换先加入新密钥并保留旧密钥,再切换 active ID;使用新的 checkpoint 执行 `--rotate=true`,验证业务数据与数据库验证值均已迁移后,运行环境才可移除旧密钥;历史备份仍需保留原密钥。工具会随 `apply --rotate=true` 迁移验证值,并保持严格状态。只切换 active ID 不代表可以删除旧密钥。
首次加密写入后,只能回滚到支持当前密文格式的应用。镜像回滚不会撤销数据库变更,也不能恢复遗失的密钥;更新清单不得声称可无条件回滚到只理解明文的旧应用。恢复数据库和队列备份时,要协调应用版本并处理备份后产生的数据。
## 本地验收
2026-10-12 使用真实 MongoDB 4.4、Redis、PostgreSQL 18、旧发布镜像及本地构建的新镜像验证升级和迁移。用户 Key 与平台凭据在仅更新镜像、接入、迁移、关闭兼容和重启阶段保持原值;文本调用连接真实供应商。Windows PowerShell 5.1 和 pwsh 7.6.5 各 33 项原生扩展检查通过,覆盖密钥独立生成、幂等、非法配置拒绝、私有 DACL 和日志泄密。生产环境未执行迁移或部署。
+2
View File
@@ -1,5 +1,7 @@
# EasyAI Windows 一键部署方案
API Key 加密和未升级脚本用户的兼容路径统一见 [API Key 加密部署与旧用户兼容](API_KEY_ENCRYPTION_DEPLOYMENT.md)。新安装自动生成独立密钥;存量部署默认保持兼容,首次接入后迁移验证并重启,原用户 Key 和平台 Key 保持不变。密钥备份/还原入口为 `scripts/Manage-CredentialKeys.ps1`,数据库归档与原密钥分开保管,按上述说明先还原密钥、重建服务再还原数据。
## 1. 背景与目标
`start.sh` 是 Linux 下的一键部署脚本,本方案为 Windows 平台提供等效的 `start.ps1` PowerShell 脚本。针对 Windows 典型场景做如下**精简**:
+1
View File
@@ -0,0 +1 @@
node_modules/
+9
View File
@@ -0,0 +1,9 @@
# 目录维护与消费验收
1. 执行 `yarn format`、`yarn validate`、`yarn test`、`yarn prepare-release --note "变更说明"`、`yarn run check`。
2. 对照当前公开版本逐条核对模型身份和定义,确认新增、修改、下线范围;未知缺失必须停止。
3. PR 合入 `main` 后,免登录读取公开维护源、`latest.json` 及指针中的版本快照;重新计算摘要并核对 Git 提交内容。
4. 使用平台真实 HTTP 和隔离 MongoDB 验证同步、重复同步、恢复停用模型、本地覆盖、主动停用、隐藏、绑定和价格保留。
5. 独立网关用真实公开 HTTP 验证指针、同源 HTTPS、内容摘要和模型解析;自定义源仍可配置。
6. 回退测试恢复历史定义并生成新版本,保留所有已有快照与 Git 历史。
7. 普通应用发布不修改模型定义。迁移不删除客户绑定、价格、权限,也不自动发起付费模型任务。
+8
View File
@@ -0,0 +1,8 @@
# 官方模型目录维护规则
- 唯一维护源为本目录 `catalog.source.json`。平台后端和独立 AI Gateway 都是只读消费者,不在应用仓库另存维护源。
- 内容和版本指针通过 Git 提交发布,公开地址见 README。禁止使用旧 OSS/CDN 上传流程;应用镜像发布不自动授权修改模型定义或生命周期。
- 变更前同步最新主线,检查新增、修改和下线差异;执行格式、Schema、测试和版本一致性检查,将维护源、版本快照和 latest.json 一并提交。
- 恢复历史定义时生成新的回退版本;不覆盖历史快照,不强推改写 Git 历史。
- 正常下线保留原目录身份并设置 offline;缺失模型默认拒绝准备版本。
- 不将客户绑定、价格、权限、账号或密钥放入公开目录。
+50
View File
@@ -0,0 +1,50 @@
# EasyAI 官方模型目录
本目录是官方基础模型定义的唯一维护位置。平台后端与独立 AI Gateway 只读消费,不另存维护源。目录内容不包含客户密钥、账号、平台绑定、价格、权限或运行策略。
- 维护 JSON:`catalog.source.json`
- 公开维护源:[catalog.source.json](https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/catalog.source.json)
- 消费端入口:[latest.json](https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json)
- Git 历史:[目录提交记录](https://git.51easyai.com/wangbo/easyai/commits/branch/main/model-catalog/official)
公开地址免登录。只有进入 `main` 的 Git 内容才会对外生效;未提交文件、临时 JSON 和普通分支不会替换正式目录。旧 OSS/CDN 上传流程已停用。
## 修改和发布
使用 Node 22.22.0、Yarn 1.22.22,在最新主线创建工作分支:
```bash
cd model-catalog/official
yarn install --frozen-lockfile --ignore-scripts
# 编辑 catalog.source.json
yarn format
yarn validate
yarn test
yarn prepare-release --note "本次模型变更说明"
yarn run check
git diff
```
`prepare-release` 只生成 Git 文件,不上传、不推送,也不切换任何线上对象。它会对照当前 Git 提交的上一版目录,默认拒绝缺少已有模型的候选。新增、能力修改和下线分别审查;正常下线保留原 `catalogKey`,将 `status` 设为 `offline`,不要通过删记录表达退役。确需物理移除时,必须同时声明 `--allow-removals --removal-reason "具体原因"`,原因及移除项会进入版本快照。
确认差异后,将维护源、`latest.json` 和本次 `releases/<版本>/catalog.json` 一起提交,经 PR 合入 `main`:
```bash
git add catalog.source.json latest.json releases/
git commit -m 'feat(catalog): 说明本次模型变更'
git push -u origin HEAD
```
平台后端配置项为 `CONFIG_OFFICIAL_MODEL_CATALOG_URL`,独立网关为 `AI_GATEWAY_OFFICIAL_MODEL_CATALOG_URL`,均使用上面的 `latest.json` 地址。目录同步后还须验收模型状态、身份和本地绑定、价格、权限不变;收录模型不代表上游账号已经可用。
## 回退
使用 `git log -- catalog.source.json` 查看历史,恢复选定提交中的定义,再准备一个新版本:
```bash
git restore --source=<历史提交> -- catalog.source.json
yarn prepare-release --note "恢复指定历史提交的模型定义"
yarn run check
```
审查差异、测试、提交并合入 `main` 后生效。回退也必须审查模型移除;历史中没有的新模型不会被静默删除。保留所有历史版本快照,不覆盖旧文件,不强推或重置公开分支。Git 提交记录用于确定修改人、时间、评审和内容差异;版本快照记录内容摘要及上一版本。
+397
View File
@@ -0,0 +1,397 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const Ajv = require("ajv");
const directory = path.dirname(fileURLToPath(import.meta.url));
const repository = path.resolve(directory, "../..");
const defaultSourcePath = path.join(directory, "catalog.source.json");
const schemaPath = path.join(directory, "catalog.schema.json");
export const publicBaseUrl =
"https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official";
const args = process.argv.slice(2);
const option = (name) => {
const index = args.indexOf("--" + name);
return index < 0 ? "" : String(args[index + 1] || "");
};
const stableValue = (value) => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") {
return Object.keys(value)
.sort()
.reduce((result, key) => {
result[key] = stableValue(value[key]);
return result;
}, {});
}
return value;
};
const checksum = (payload) =>
crypto
.createHash("sha256")
.update(JSON.stringify(stableValue(payload)))
.digest("hex");
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const serializeJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
function canonicalizeSource(source) {
return {
...source,
baseModels: source.baseModels.map((model) => {
const current = model.capabilities || {};
const capabilityKeys = [
...model.modelTypes,
...Object.keys(current).filter(
(key) => !model.modelTypes.includes(key),
),
];
const capabilities = Object.fromEntries(
capabilityKeys.map((type) => {
const { type: _ignoredType, ...config } = current[type] || {};
return [type, { type, ...config }];
}),
);
return { ...model, capabilities };
}),
};
}
function assertOrderedRanges(value, pathParts = []) {
if (Array.isArray(value)) {
const field = pathParts.at(-1) || "";
if (
(field.endsWith("_range") || field === "thinkingBudgetTokensRange") &&
value.length === 2 &&
value.every((item) => typeof item === "number") &&
value[0] > value[1]
) {
throw new Error(`范围上下限颠倒: ${pathParts.join(".")}`);
}
value.forEach((item, index) =>
assertOrderedRanges(item, [...pathParts, String(index)]),
);
return;
}
if (!value || typeof value !== "object") return;
if (
typeof value.min === "number" &&
typeof value.max === "number" &&
value.min > value.max
) {
throw new Error(`范围上下限颠倒: ${pathParts.join(".")}`);
}
Object.entries(value).forEach(([key, item]) =>
assertOrderedRanges(item, [...pathParts, key]),
);
}
export function validateSource(file = defaultSourcePath) {
const sourceText = fs.readFileSync(file, "utf8");
const source = JSON.parse(sourceText);
const ajv = new Ajv({ allErrors: true, strict: false });
const validate = ajv.compile(readJson(schemaPath));
if (!validate(source)) {
throw new Error(
`官方目录格式无效: ${ajv.errorsText(validate.errors, { separator: "; " })}`,
);
}
const providerCodes = new Set(source.providers.map((item) => item.code));
if (providerCodes.size !== source.providers.length) {
throw new Error("官方目录包含重复厂商 code");
}
const keys = new Set();
const identities = new Set();
for (const model of source.baseModels) {
if (keys.has(model.catalogKey))
throw new Error(`重复 catalogKey: ${model.catalogKey}`);
keys.add(model.catalogKey);
const identity = `${model.alias}\u0000${model.defaultRealModelName}`;
if (identities.has(identity))
throw new Error(
`重复模型身份: ${model.alias} / ${model.defaultRealModelName}`,
);
identities.add(identity);
if (model.providerCode && !providerCodes.has(model.providerCode)) {
throw new Error(
`模型 ${model.catalogKey} 引用了不存在的厂商 ${model.providerCode}`,
);
}
const capabilityKeys = Object.keys(model.capabilities);
const missingCapabilities = model.modelTypes.filter(
(type) => !capabilityKeys.includes(type),
);
const extraCapabilities = capabilityKeys.filter(
(type) => !model.modelTypes.includes(type),
);
if (missingCapabilities.length || extraCapabilities.length) {
throw new Error(
`模型 ${model.catalogKey} 的 modelTypes 与 capabilities 不一致` +
`(缺少: ${missingCapabilities.join(", ") || "无"};` +
`多余: ${extraCapabilities.join(", ") || "无"})`,
);
}
for (const [type, capability] of Object.entries(model.capabilities)) {
if (capability.type !== type) {
throw new Error(
`模型 ${model.catalogKey} 的能力 ${type}.type 必须等于 ${type}`,
);
}
}
const textToSpeech = model.capabilities.text_to_speech;
if (textToSpeech) {
const voices = Array.isArray(textToSpeech.voices)
? textToSpeech.voices
: [];
const voiceIds = voices.map((voice) => voice.id);
if (new Set(voiceIds).size !== voiceIds.length) {
throw new Error(`模型 ${model.catalogKey} 包含重复音色 ID`);
}
if (
textToSpeech.default_voice_id &&
!voiceIds.includes(textToSpeech.default_voice_id)
) {
throw new Error(
`模型 ${model.catalogKey} 的默认音色不在 voices 中: ${textToSpeech.default_voice_id}`,
);
}
}
if (model.catalogKey.startsWith("base:seed:")) {
if (!model.seedKey)
throw new Error(`种子模型 ${model.catalogKey} 必须包含 seedKey`);
} else if (model.seedKey !== undefined) {
throw new Error(`运行时模型 ${model.catalogKey} 不应包含 seedKey`);
}
assertOrderedRanges(model.capabilities, [model.catalogKey, "capabilities"]);
}
const canonicalText = serializeJson(canonicalizeSource(source));
if (sourceText !== canonicalText) {
throw new Error("官方目录格式不规范,请运行 yarn format 后再提交");
}
return source;
}
export function buildRelease(
source,
{ version, note = "", previousVersion = null } = {},
) {
const publishedAt = new Date().toISOString();
const selectedVersion = version || publishedAt.replace(/[-:.]/g, "");
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(selectedVersion))
throw new Error("目录版本格式无效");
const payload = {
schemaVersion: source.schemaVersion,
providers: source.providers,
baseModels: source.baseModels,
providerModelPresets: [],
};
return {
version: selectedVersion,
publishedAt,
generatedAt: publishedAt,
...payload,
checksum: checksum(payload),
count: source.baseModels.length,
ttlSeconds: 21600,
previousVersion,
sourceRepository: "https://git.51easyai.com/wangbo/easyai",
sourcePath: "model-catalog/official/catalog.source.json",
...(note ? { note } : {}),
};
}
export function assertNoMissingModels(
previous,
source,
{ allowRemovals = false, removalReason = "" } = {},
) {
const keys = new Set(source.baseModels.map((model) => model.catalogKey));
const removed = (previous?.baseModels || [])
.filter((model) => !keys.has(model.catalogKey))
.map((model) => ({ catalogKey: model.catalogKey, alias: model.alias }));
if (removed.length && (!allowRemovals || !removalReason.trim())) {
throw new Error(
`维护源遗漏已发布模型:${JSON.stringify(removed)}。下线请保留原身份并设置 status=offline;确需移除时须声明 --allow-removals --removal-reason。`,
);
}
return removed;
}
export function prepareArtifacts(targetDirectory = directory, options = {}) {
const source = validateSource(
path.join(targetDirectory, "catalog.source.json"),
);
const removed = assertNoMissingModels(options.previous, source, options);
const release = buildRelease(source, {
...options,
previousVersion: options.previous?.version || null,
});
if (removed.length)
release.removals = {
models: removed,
reason: options.removalReason.trim(),
};
const releasePath = path.join(
targetDirectory,
"releases",
release.version,
"catalog.json",
);
if (fs.existsSync(releasePath))
throw new Error("该版本快照已存在,请生成新版本,不能覆盖历史文件");
const pointer = {
schemaVersion: 1,
version: release.version,
checksum: release.checksum,
publishedAt: release.publishedAt,
catalogUrl: `${publicBaseUrl}/releases/${release.version}/catalog.json`,
};
fs.mkdirSync(path.dirname(releasePath), { recursive: true });
fs.writeFileSync(releasePath, serializeJson(release));
fs.writeFileSync(
path.join(targetDirectory, "latest.json"),
serializeJson(pointer),
);
return { pointer, release };
}
export function checkArtifacts(targetDirectory = directory) {
const source = validateSource(
path.join(targetDirectory, "catalog.source.json"),
);
const pointer = readJson(path.join(targetDirectory, "latest.json"));
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(pointer.version))
throw new Error("目录版本格式无效");
const release = readJson(
path.join(targetDirectory, "releases", pointer.version, "catalog.json"),
);
const payload = {
schemaVersion: source.schemaVersion,
providers: source.providers,
baseModels: source.baseModels,
providerModelPresets: [],
};
if (
pointer.version !== release.version ||
pointer.checksum !== release.checksum ||
release.checksum !== checksum(payload) ||
release.checksum !==
checksum({
schemaVersion: release.schemaVersion,
providers: release.providers,
baseModels: release.baseModels,
providerModelPresets: release.providerModelPresets || [],
})
) {
throw new Error(
"维护源、最新指针和版本快照不一致,请重新准备版本并一并提交",
);
}
if (
pointer.catalogUrl !==
`${publicBaseUrl}/releases/${release.version}/catalog.json`
)
throw new Error("目录快照必须指向 easyai 仓库公开地址");
return {
version: release.version,
checksum: release.checksum,
baseModelCount: release.baseModels.length,
catalogUrl: pointer.catalogUrl,
};
}
function previousCommittedRelease() {
const git = (file) =>
JSON.parse(
execFileSync("git", ["show", `HEAD:model-catalog/official/${file}`], {
cwd: repository,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 4 * 1024 * 1024,
}),
);
let pointer;
try {
pointer = git("latest.json");
} catch (error) {
// A missing pointer is only allowed for this repository's first catalog commit.
try {
execFileSync(
"git",
["cat-file", "-e", "HEAD:model-catalog/official/latest.json"],
{ cwd: repository, stdio: "ignore" },
);
} catch {
return null;
}
throw error;
}
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,95}$/.test(pointer.version))
throw new Error("已提交目录版本格式无效");
return git(`releases/${pointer.version}/catalog.json`);
}
function main() {
const command = args[0] || "validate";
if (command === "format") {
fs.writeFileSync(
defaultSourcePath,
serializeJson(canonicalizeSource(readJson(defaultSourcePath))),
);
validateSource();
console.log(JSON.stringify({ action: "formatted" }));
} else if (command === "validate") {
const source = validateSource();
console.log(
JSON.stringify({
action: "validated",
baseModelCount: source.baseModels.length,
providerCount: source.providers.length,
}),
);
} else if (command === "prepare") {
const result = prepareArtifacts(directory, {
previous: previousCommittedRelease(),
version: option("version") || undefined,
note: option("note"),
allowRemovals: args.includes("--allow-removals"),
removalReason: option("removal-reason"),
});
console.log(
JSON.stringify(
{
action: "prepared-for-git-commit",
version: result.pointer.version,
checksum: result.pointer.checksum,
baseModelCount: result.release.count,
instruction:
"检查 Git 差异后,将维护源、latest.json 和版本快照一并提交并推送;公开 JSON 只来自 Git 已提交内容。",
},
null,
2,
),
);
} else if (command === "check") {
console.log(JSON.stringify(checkArtifacts(), null, 2));
} else {
throw new Error(
"支持 format、validate、prepare、check;目录通过 Git 提交发布,不再使用 OSS/CDN 上传命令。",
);
}
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+846
View File
@@ -0,0 +1,846 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EasyAI Official Base Model Catalog Source",
"type": "object",
"required": ["schemaVersion", "providers", "baseModels"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": 1 },
"providers": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/provider" }
},
"baseModels": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/baseModel" }
}
},
"definitions": {
"nonEmptyString": { "type": "string", "minLength": 1 },
"nonNegativeInteger": { "type": "integer", "minimum": 0 },
"positiveInteger": { "type": "integer", "minimum": 1 },
"stringList": {
"type": "array",
"items": { "type": "string", "minLength": 1 },
"uniqueItems": true
},
"numberList": {
"type": "array",
"items": { "type": "number" }
},
"integerList": {
"type": "array",
"items": { "type": "integer" },
"uniqueItems": true
},
"numberRange": {
"type": "array",
"items": { "type": "number" },
"minItems": 2,
"maxItems": 2
},
"numberRangeOrMap": {
"oneOf": [
{ "$ref": "#/definitions/numberRange" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/numberRange" }
}
]
},
"numberListOrMap": {
"oneOf": [
{ "$ref": "#/definitions/numberList" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/numberList" }
}
]
},
"stringListOrMap": {
"oneOf": [
{ "$ref": "#/definitions/stringList" },
{
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/stringList" }
}
]
},
"capabilityName": {
"enum": [
"audio_generate",
"audio_understanding",
"digital_human_generate",
"image_analysis",
"image_edit",
"image_generate",
"image_to_model",
"image_to_video",
"image_vectorize",
"mesh_edit",
"multiview_to_model",
"omni",
"omni_video",
"speech_to_text",
"text_embedding",
"text_generate",
"text_rerank",
"text_to_model",
"text_to_speech",
"tools_call",
"video_enhance",
"video_generate",
"video_understanding",
"voice_clone"
]
},
"provider": {
"type": "object",
"required": ["code", "name", "displayName"],
"properties": {
"code": { "$ref": "#/definitions/nonEmptyString" },
"name": { "$ref": "#/definitions/nonEmptyString" },
"displayName": { "$ref": "#/definitions/nonEmptyString" },
"iconPath": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"baseModel": {
"type": "object",
"required": [
"catalogKey",
"providerCode",
"displayName",
"alias",
"defaultRealModelName",
"modelTypes",
"capabilities",
"status"
],
"properties": {
"catalogKey": {
"type": "string",
"pattern": "^base:(runtime|seed):.+"
},
"seedKey": { "$ref": "#/definitions/nonEmptyString" },
"providerCode": { "$ref": "#/definitions/nonEmptyString" },
"displayName": { "$ref": "#/definitions/nonEmptyString" },
"alias": { "$ref": "#/definitions/nonEmptyString" },
"defaultRealModelName": { "$ref": "#/definitions/nonEmptyString" },
"modelTypes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/capabilityName" }
},
"iconPath": { "$ref": "#/definitions/nonEmptyString" },
"description": { "$ref": "#/definitions/nonEmptyString" },
"capabilities": { "$ref": "#/definitions/capabilities" },
"status": { "enum": ["active", "deprecated", "offline"] }
},
"additionalProperties": false
},
"languageCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "$ref": "#/definitions/capabilityName" },
"max_context_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_input_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_output_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"max_thinking_tokens": { "$ref": "#/definitions/nonNegativeInteger" },
"outputTokenLimitScope": { "enum": ["total", "answer_only"] },
"outputTokenParameter": {
"enum": ["max_tokens", "max_completion_tokens"]
},
"defaultOutputTokenBudget": { "const": "candidate_max" },
"supportThinking": { "type": "boolean" },
"supportThinkingModeSwitch": { "type": "boolean" },
"supportStructuredOutput": { "type": "boolean" },
"supportTool": { "type": "boolean" },
"supportWebSearch": { "type": "boolean" },
"thinkingBudgetTokensRange": { "$ref": "#/definitions/numberRange" },
"thinkingEffortLevels": { "$ref": "#/definitions/stringList" },
"supportedApiProtocols": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"textEmbeddingCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_embedding" },
"dimensions": {
"type": "array",
"items": { "$ref": "#/definitions/positiveInteger" },
"uniqueItems": true
}
},
"additionalProperties": false
},
"textRerankCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_rerank" },
"maxDocuments": { "$ref": "#/definitions/positiveInteger" },
"maxRequestTokens": { "$ref": "#/definitions/positiveInteger" },
"maxTokensPerDocument": { "$ref": "#/definitions/positiveInteger" },
"supportInstruct": { "type": "boolean" },
"supportTopN": { "type": "boolean" }
},
"additionalProperties": false
},
"inputParameter": {
"type": "object",
"required": ["type", "parameter"],
"properties": {
"type": { "$ref": "#/definitions/nonEmptyString" },
"parameter": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"imageCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["image_generate", "image_edit"] },
"allow_custom_width_height_size": { "type": "boolean" },
"aspect_ratio_allowed": { "$ref": "#/definitions/stringList" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"input_format_allowed": { "$ref": "#/definitions/stringList" },
"input_max_file_size_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_multiple_images": { "type": "boolean" },
"input_output_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"input_parameters": {
"type": "array",
"items": { "$ref": "#/definitions/inputParameter" }
},
"input_size_range": { "$ref": "#/definitions/stringList" },
"output_max_images_count": {
"$ref": "#/definitions/nonNegativeInteger"
},
"output_max_size": { "$ref": "#/definitions/nonNegativeInteger" },
"output_multiple_images": { "type": "boolean" },
"output_resolutions": { "$ref": "#/definitions/stringList" },
"output_size_range": { "$ref": "#/definitions/numberRange" },
"quality_options": { "$ref": "#/definitions/stringList" },
"size_param_format": { "$ref": "#/definitions/nonEmptyString" },
"support_quality_control": { "type": "boolean" },
"width_height_multiple": { "$ref": "#/definitions/positiveInteger" },
"width_height_range": { "$ref": "#/definitions/numberRange" }
},
"additionalProperties": false
},
"promptLengthLimit": {
"type": "object",
"required": ["max"],
"properties": {
"max": { "$ref": "#/definitions/positiveInteger" },
"count_mode": { "$ref": "#/definitions/nonEmptyString" },
"label": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"resolutionRoute": {
"type": "object",
"required": [
"mode",
"generation_resolution",
"target_resolution",
"display_resolution",
"enhance_model",
"fallback_policy",
"billing_mode"
],
"properties": {
"mode": { "$ref": "#/definitions/nonEmptyString" },
"generation_resolution": { "$ref": "#/definitions/nonEmptyString" },
"target_resolution": { "$ref": "#/definitions/nonEmptyString" },
"display_resolution": { "$ref": "#/definitions/nonEmptyString" },
"enhance_model": { "$ref": "#/definitions/nonEmptyString" },
"fallback_policy": { "$ref": "#/definitions/nonEmptyString" },
"billing_mode": { "$ref": "#/definitions/nonEmptyString" }
},
"additionalProperties": false
},
"resolutionRoutes": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/resolutionRoute" }
},
"modeConstraint": {
"type": "object",
"properties": {
"aspect_ratio_allowed": { "$ref": "#/definitions/stringList" },
"forced_aspect_ratio": { "$ref": "#/definitions/nonEmptyString" },
"prompt_required": { "type": "boolean" }
},
"additionalProperties": false
},
"modeConstraints": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/modeConstraint" }
},
"mediaConstraint": {
"type": "object",
"properties": {
"formats": { "$ref": "#/definitions/stringList" },
"codecs": { "$ref": "#/definitions/stringList" },
"max_size_mb": { "type": "number", "minimum": 0 },
"max_count": { "$ref": "#/definitions/nonNegativeInteger" },
"min_dimension": { "$ref": "#/definitions/nonNegativeInteger" },
"max_dimension": { "$ref": "#/definitions/nonNegativeInteger" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"duration_range": { "$ref": "#/definitions/numberRange" },
"max_total_duration_seconds": { "type": "number", "minimum": 0 },
"fps_range": { "$ref": "#/definitions/numberRange" },
"requires_visual_reference": { "type": "boolean" }
},
"additionalProperties": false
},
"inputMediaConstraints": {
"type": "object",
"required": ["max_request_size_mb"],
"properties": {
"max_request_size_mb": { "type": "number", "minimum": 0 },
"image": { "$ref": "#/definitions/mediaConstraint" },
"video": { "$ref": "#/definitions/mediaConstraint" },
"audio": { "$ref": "#/definitions/mediaConstraint" }
},
"additionalProperties": false
},
"providerOperations": {
"type": "object",
"properties": {
"context_ir": { "type": "boolean" },
"regeneration": {
"type": "object",
"required": [
"source_resolutions",
"target_resolutions",
"requires_audio",
"source_fps"
],
"properties": {
"source_resolutions": { "$ref": "#/definitions/stringList" },
"target_resolutions": { "$ref": "#/definitions/stringList" },
"requires_audio": { "type": "boolean" },
"source_fps": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"limitsWithVideo": {
"type": "object",
"properties": {
"max_images_and_elements": {
"$ref": "#/definitions/nonNegativeInteger"
},
"duration_options": { "$ref": "#/definitions/numberList" }
},
"additionalProperties": false
},
"videoDraftCapability": {
"type": "object",
"additionalProperties": false,
"required": [
"enabled",
"contract_version",
"draft_resolution",
"final_resolutions",
"validity_seconds",
"input_modes"
],
"properties": {
"enabled": {
"type": "boolean"
},
"contract_version": {
"enum": [
1,
2
]
},
"draft_resolution": {
"enum": [
"480p",
"720p"
]
},
"final_resolutions": {
"type": "array",
"minItems": 1,
"maxItems": 1,
"items": {
"enum": [
"1080p",
"1440p"
]
}
},
"validity_seconds": {
"type": "integer", "minimum": 0
},
"input_modes": {
"$ref": "#/definitions/stringList"
},
"finalization_protocol": {
"enum": [
"minimax_h3_media_context",
"easyai_result_reference"
]
}
},
"allOf": [
{
"if": {
"properties": {
"contract_version": {
"const": 1
}
}
},
"then": {
"properties": {
"validity_seconds": { "const": 604800 },
"draft_resolution": {
"const": "480p"
},
"final_resolutions": {
"items": {
"const": "1080p"
}
}
}
},
"else": {
"required": [
"finalization_protocol"
],
"properties": {
"draft_resolution": {
"const": "720p"
},
"final_resolutions": {
"items": {
"const": "1440p"
}
}
}
}
}
]
},
"videoCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "enum": ["video_generate", "image_to_video", "omni_video"] },
"draft_mode": { "$ref": "#/definitions/videoDraftCapability" },
"aspect_ratio_allowed": { "$ref": "#/definitions/stringListOrMap" },
"aspect_ratio_range": { "$ref": "#/definitions/numberRange" },
"duration_options": { "$ref": "#/definitions/numberListOrMap" },
"duration_range": { "$ref": "#/definitions/numberRangeOrMap" },
"input_audio": { "type": "boolean" },
"input_first_frame": { "type": "boolean" },
"input_first_last_frame": { "type": "boolean" },
"input_last_frame": { "type": "boolean" },
"input_media_constraints": {
"$ref": "#/definitions/inputMediaConstraints"
},
"input_reference_generate_multiple": { "type": "boolean" },
"input_reference_generate_single": { "type": "boolean" },
"input_size_limit": { "type": "number", "minimum": 0 },
"input_smart_multi_frame": { "type": "boolean" },
"input_width_height_range": { "$ref": "#/definitions/numberRange" },
"limits_with_video": { "$ref": "#/definitions/limitsWithVideo" },
"max_audios": { "$ref": "#/definitions/nonNegativeInteger" },
"max_elements": { "$ref": "#/definitions/nonNegativeInteger" },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"max_images_and_elements": {
"$ref": "#/definitions/nonNegativeInteger"
},
"max_images_for_last_frame": {
"$ref": "#/definitions/nonNegativeInteger"
},
"max_videos": { "$ref": "#/definitions/nonNegativeInteger" },
"mode_constraints": { "$ref": "#/definitions/modeConstraints" },
"output_audio": { "type": "boolean" },
"output_audio_mode": { "$ref": "#/definitions/nonEmptyString" },
"output_bgm": { "type": "boolean" },
"output_resolutions": { "$ref": "#/definitions/stringListOrMap" },
"output_support_return_last_frame": { "type": "boolean" },
"output_video_continuation": { "type": "boolean" },
"prompt_length_limit": { "$ref": "#/definitions/promptLengthLimit" },
"provider_operations": { "$ref": "#/definitions/providerOperations" },
"resolution_routes": { "$ref": "#/definitions/resolutionRoutes" },
"size_allowed": { "$ref": "#/definitions/stringListOrMap" },
"smart_multi_frame_mode": { "$ref": "#/definitions/nonEmptyString" },
"smart_multi_frame_range": { "$ref": "#/definitions/numberRange" },
"support_instruction_edit": { "type": "boolean" },
"support_video_effect_template": { "type": "boolean" },
"support_video_effect_template_list": {
"$ref": "#/definitions/stringList"
},
"supported_modes": { "$ref": "#/definitions/stringList" },
"supports_portrait_asset_reference": { "type": "boolean" },
"supportWebSearch": { "type": "boolean" }
},
"additionalProperties": false
},
"faceLimitRange": {
"type": "object",
"required": ["min", "max"],
"properties": {
"min": { "$ref": "#/definitions/nonNegativeInteger" },
"max": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
},
"faceLimitByGeometryQuality": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"properties": {
"triangle": { "$ref": "#/definitions/nonNegativeInteger" },
"quad": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
}
},
"faceLimitRangeByTopology": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "$ref": "#/definitions/faceLimitRange" }
},
"crossProviderEdit": {
"type": "object",
"properties": {
"tripo3d_import_supported": { "type": "boolean" }
},
"additionalProperties": false
},
"multiviewInput": {
"type": "object",
"required": ["min_images", "max_images", "views"],
"properties": {
"min_images": { "$ref": "#/definitions/nonNegativeInteger" },
"max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"views": {
"type": "array",
"items": {
"type": "object",
"required": ["type", "order"],
"properties": {
"type": { "$ref": "#/definitions/nonEmptyString" },
"required": { "type": "boolean" },
"order": { "$ref": "#/definitions/nonNegativeInteger" }
},
"additionalProperties": false
}
},
"file_constraints": {
"type": "object",
"properties": {
"allowed_mime_types": { "$ref": "#/definitions/stringList" },
"min_side": { "$ref": "#/definitions/nonNegativeInteger" },
"max_side": { "$ref": "#/definitions/nonNegativeInteger" },
"per_file_max_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
},
"auxiliary_total_max_bytes": {
"$ref": "#/definitions/nonNegativeInteger"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"model3dCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": {
"enum": ["text_to_model", "image_to_model", "multiview_to_model"]
},
"cross_provider_edit": { "$ref": "#/definitions/crossProviderEdit" },
"default_geometry_instruct_mode": {
"$ref": "#/definitions/nonEmptyString"
},
"default_output_format": { "$ref": "#/definitions/nonEmptyString" },
"face_limit_by_geometry_quality": {
"$ref": "#/definitions/faceLimitByGeometryQuality"
},
"face_limit_range": { "$ref": "#/definitions/faceLimitRange" },
"face_limit_range_by_topology": {
"$ref": "#/definitions/faceLimitRangeByTopology"
},
"geometry_instruct_modes": { "$ref": "#/definitions/stringList" },
"geometry_quality_options": { "$ref": "#/definitions/stringList" },
"max_face_limit": { "$ref": "#/definitions/nonNegativeInteger" },
"max_face_limit_quad": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_input": { "$ref": "#/definitions/multiviewInput" },
"multiview_max_images": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_min_images": { "$ref": "#/definitions/nonNegativeInteger" },
"multiview_required_slots": { "$ref": "#/definitions/stringList" },
"output_formats": { "$ref": "#/definitions/stringList" },
"pbr_required_when_texture": { "type": "boolean" },
"provider_generation_only": { "type": "boolean" },
"support_export_uv": { "type": "boolean" },
"support_image_autofix": { "type": "boolean" },
"support_micro_detail": { "type": "boolean" },
"support_part_generation": { "type": "boolean" },
"support_pbr": { "type": "boolean" },
"support_public_image_url_only": { "type": "boolean" },
"support_quad": { "type": "boolean" },
"support_smart_low_poly": { "type": "boolean" },
"support_ta_pose": { "type": "boolean" },
"support_texture": { "type": "boolean" },
"support_texture_delight": { "type": "boolean" },
"texture_quality_options": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"meshEditCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "mesh_edit" },
"supported_operations": {
"type": "object",
"minProperties": 1,
"additionalProperties": {
"type": "object",
"required": ["supported"],
"properties": {
"supported": { "type": "boolean" },
"supported_formats": { "$ref": "#/definitions/stringList" },
"supported_texture_prompt_modes": {
"$ref": "#/definitions/stringList"
}
},
"additionalProperties": false
}
}
},
"additionalProperties": false
},
"audioGenerateCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "audio_generate" },
"output_formats": { "$ref": "#/definitions/stringList" },
"supports_instrumental": { "type": "boolean" },
"supports_lyrics": { "type": "boolean" },
"supports_lyrics_optimizer": { "type": "boolean" },
"supports_prompt": { "type": "boolean" }
},
"additionalProperties": false
},
"speechToTextCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "speech_to_text" },
"endpoint_path": { "$ref": "#/definitions/nonEmptyString" },
"input_key": { "$ref": "#/definitions/nonEmptyString" },
"max_duration_seconds": { "type": "number", "minimum": 0 },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"mode": { "$ref": "#/definitions/nonEmptyString" },
"poll_interval_ms": { "$ref": "#/definitions/nonNegativeInteger" },
"poll_timeout_ms": { "$ref": "#/definitions/nonNegativeInteger" },
"query_url": { "$ref": "#/definitions/nonEmptyString" },
"resource_id": { "$ref": "#/definitions/nonEmptyString" },
"submit_url": { "$ref": "#/definitions/nonEmptyString" },
"supported_formats": { "$ref": "#/definitions/stringList" },
"supports_diarization": { "type": "boolean" },
"supports_emotion": { "type": "boolean" },
"supports_hotwords": { "type": "boolean" },
"supports_prompt_context": { "type": "boolean" },
"supports_timestamp": { "type": "boolean" }
},
"additionalProperties": false
},
"voice": {
"type": "object",
"required": ["id", "name"],
"properties": {
"accent": { "type": "string" },
"age": { "type": "string" },
"audio": { "type": "string" },
"desc": { "type": "string" },
"gender": { "type": "string" },
"icon": { "type": "string" },
"id": { "$ref": "#/definitions/nonEmptyString" },
"lang": { "type": "string" },
"name": { "$ref": "#/definitions/nonEmptyString" },
"provider": { "$ref": "#/definitions/nonEmptyString" },
"tags": { "$ref": "#/definitions/stringList" }
},
"additionalProperties": false
},
"ttsParamsCapability": {
"type": "object",
"properties": {
"output_audio": { "type": "boolean" },
"voice_modify": { "type": "boolean" },
"emotion": { "type": "boolean" },
"pronunciation_dict": { "type": "boolean" },
"language_boost": { "type": "boolean" },
"language_controls": { "type": "boolean" },
"text_filters": { "type": "boolean" },
"voice_instruction": { "type": "boolean" },
"audio_formats": { "$ref": "#/definitions/stringList" },
"sample_rates": { "$ref": "#/definitions/integerList" },
"opus_sample_rates": { "$ref": "#/definitions/integerList" },
"bit_rates": { "$ref": "#/definitions/integerList" },
"volume_range": {
"type": "object",
"required": ["min", "max", "step"],
"properties": {
"min": { "type": "number", "minimum": 0 },
"max": { "type": "number", "minimum": 0 },
"step": { "type": "number", "exclusiveMinimum": 0 }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"textToSpeechCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "text_to_speech" },
"tts_params": {
"oneOf": [{ "const": false }, { "$ref": "#/definitions/ttsParamsCapability" }]
},
"voice_compatibility_group": {
"$ref": "#/definitions/nonEmptyString"
},
"default_voice_id": { "$ref": "#/definitions/nonEmptyString" },
"resource_id": { "$ref": "#/definitions/nonEmptyString" },
"voices": {
"type": "array",
"items": { "$ref": "#/definitions/voice" }
}
},
"additionalProperties": false
},
"audioInputConstraint": {
"type": "object",
"required": ["formats", "max_duration_seconds", "max_size_mb"],
"properties": {
"formats": { "$ref": "#/definitions/stringList" },
"min_duration_seconds": { "type": "number", "minimum": 0 },
"max_duration_seconds": { "type": "number", "minimum": 0 },
"max_size_mb": { "type": "number", "minimum": 0 }
},
"additionalProperties": false
},
"voiceCloneCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "voice_clone" },
"preview_models": { "$ref": "#/definitions/stringList" },
"prompt_audio": { "$ref": "#/definitions/audioInputConstraint" },
"source_audio": { "$ref": "#/definitions/audioInputConstraint" }
},
"additionalProperties": false
},
"imageVectorizeCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "image_vectorize" },
"async_task": { "type": "boolean" },
"cleanup_levels": { "$ref": "#/definitions/stringList" },
"input_format_allowed": { "$ref": "#/definitions/stringList" },
"max_colors_options": { "$ref": "#/definitions/integerList" },
"output_format_allowed": { "$ref": "#/definitions/stringList" },
"support_base64_input": { "type": "boolean" },
"support_url_input": { "type": "boolean" },
"supports_extra_format_download": { "type": "boolean" },
"supports_preview": { "type": "boolean" }
},
"additionalProperties": false
},
"videoEnhanceCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "video_enhance" },
"async_task": { "type": "boolean" },
"input_resolutions": { "$ref": "#/definitions/stringList" },
"input_video_required": { "type": "boolean" },
"max_file_size_mb": { "type": "number", "minimum": 0 },
"operations": { "$ref": "#/definitions/stringList" },
"output_resolutions": { "$ref": "#/definitions/stringList" },
"preserve_audio": { "type": "boolean" }
},
"additionalProperties": false
},
"digitalHumanCapability": {
"type": "object",
"required": ["type"],
"properties": {
"type": { "const": "digital_human_generate" }
},
"additionalProperties": false
},
"capabilities": {
"type": "object",
"minProperties": 1,
"properties": {
"audio_generate": { "$ref": "#/definitions/audioGenerateCapability" },
"audio_understanding": { "$ref": "#/definitions/languageCapability" },
"digital_human_generate": {
"$ref": "#/definitions/digitalHumanCapability"
},
"image_analysis": { "$ref": "#/definitions/languageCapability" },
"image_edit": { "$ref": "#/definitions/imageCapability" },
"image_generate": { "$ref": "#/definitions/imageCapability" },
"image_to_model": { "$ref": "#/definitions/model3dCapability" },
"image_to_video": { "$ref": "#/definitions/videoCapability" },
"image_vectorize": { "$ref": "#/definitions/imageVectorizeCapability" },
"mesh_edit": { "$ref": "#/definitions/meshEditCapability" },
"multiview_to_model": { "$ref": "#/definitions/model3dCapability" },
"omni": { "$ref": "#/definitions/languageCapability" },
"omni_video": { "$ref": "#/definitions/videoCapability" },
"speech_to_text": { "$ref": "#/definitions/speechToTextCapability" },
"text_embedding": { "$ref": "#/definitions/textEmbeddingCapability" },
"text_generate": { "$ref": "#/definitions/languageCapability" },
"text_rerank": { "$ref": "#/definitions/textRerankCapability" },
"text_to_model": { "$ref": "#/definitions/model3dCapability" },
"text_to_speech": { "$ref": "#/definitions/textToSpeechCapability" },
"tools_call": { "$ref": "#/definitions/languageCapability" },
"video_enhance": { "$ref": "#/definitions/videoEnhanceCapability" },
"video_generate": { "$ref": "#/definitions/videoCapability" },
"video_understanding": { "$ref": "#/definitions/languageCapability" },
"voice_clone": { "$ref": "#/definitions/voiceCloneCapability" }
},
"additionalProperties": false
}
}
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
assertNoMissingModels,
buildRelease,
checkArtifacts,
prepareArtifacts,
publicBaseUrl,
validateSource,
} from "./catalog.mjs";
const directory = path.dirname(new URL(import.meta.url).pathname);
const source = JSON.parse(
fs.readFileSync(path.join(directory, "catalog.source.json"), "utf8"),
);
function fixture(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "easyai-git-catalog-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const data = structuredClone(source);
data.baseModels = data.baseModels.slice(0, 2);
const save = () =>
fs.writeFileSync(
path.join(dir, "catalog.source.json"),
JSON.stringify(data, null, 2) + "\n",
);
save();
return { dir, data, save };
}
test("唯一维护源通过完整 Schema 和规范格式校验", () => {
const data = validateSource();
assert.ok(data.baseModels.length >= 220);
assert.equal(data.providers.length, 27);
});
test("Nano Banana 2.1 保留唯一原身份和图像能力", () => {
const rows = source.baseModels.filter(
(model) => model.defaultRealModelName === "gemini-nano-banana-2.1",
);
assert.equal(rows.length, 1);
assert.equal(
rows[0].catalogKey,
"base:runtime:google-gemini:Nano Banana 2.1:gemini-nano-banana-2.1",
);
assert.equal(rows[0].alias, "Nano Banana 2.1");
assert.equal(rows[0].status, "active");
assert.deepEqual(rows[0].modelTypes, ["image_generate", "image_edit"]);
assert.deepEqual(rows[0].capabilities.image_generate.output_resolutions, [
"1K",
"2K",
"4K",
]);
assert.equal(rows[0].capabilities.image_edit.input_max_images_count, 14);
});
test("重复模型身份拒绝保存", (t) => {
const { dir, data, save } = fixture(t);
data.baseModels.push(structuredClone(data.baseModels[0]));
save();
assert.throws(
() => validateSource(path.join(dir, "catalog.source.json")),
/重复/,
);
});
test("能力字段拼写和类型受 Schema 约束", (t) => {
const { dir, data, save } = fixture(t);
const type = data.baseModels[0].modelTypes[0];
data.baseModels[0].capabilities[type].misspelledCapability = true;
save();
assert.throws(
() => validateSource(path.join(dir, "catalog.source.json")),
/additional properties/,
);
});
test("准备版本时同步生成公开指针和校验和匹配的快照", (t) => {
const { dir } = fixture(t);
const { pointer } = prepareArtifacts(dir, { version: "test-v1" });
assert.equal(
pointer.catalogUrl,
publicBaseUrl + "/releases/test-v1/catalog.json",
);
assert.equal(checkArtifacts(dir).version, "test-v1");
});
test("只改维护源而未重新生成快照会被检查拒绝", (t) => {
const { dir, data, save } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
data.baseModels[0].displayName = "修改后的名称";
save();
assert.throws(() => checkArtifacts(dir), /不一致/);
});
test("篡改版本快照会被拒绝", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
const file = path.join(dir, "releases/test-v1/catalog.json");
const release = JSON.parse(fs.readFileSync(file));
release.baseModels[0].displayName = "篡改";
fs.writeFileSync(file, JSON.stringify(release));
assert.throws(() => checkArtifacts(dir), /不一致/);
});
test("遗漏已发布模型默认拒绝,正常下线保留原身份", (t) => {
const { data } = fixture(t);
const previous = buildRelease(data, { version: "test-v1" });
const reduced = structuredClone(data);
reduced.baseModels.pop();
assert.throws(
() => assertNoMissingModels(previous, reduced),
/遗漏已发布模型/,
);
data.baseModels[0].status = "offline";
assert.deepEqual(assertNoMissingModels(previous, data), []);
});
test("物理移除必须同时声明允许和原因,并记录在新版本中", (t) => {
const { dir, data, save } = fixture(t);
const { release: previous } = prepareArtifacts(dir, { version: "test-v1" });
data.baseModels.pop();
save();
assert.throws(
() => prepareArtifacts(dir, { previous, allowRemovals: true }),
/遗漏/,
);
assert.throws(
() => prepareArtifacts(dir, { previous, removalReason: "迁移" }),
/遗漏/,
);
const { release } = prepareArtifacts(dir, {
previous,
version: "test-v2",
allowRemovals: true,
removalReason: "明确移除",
});
assert.equal(release.removals.models.length, 1);
assert.equal(release.removals.reason, "明确移除");
assert.equal(checkArtifacts(dir).version, "test-v2");
});
test("历史快照不能覆盖且版本不能包含目录穿越", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
assert.throws(
() => prepareArtifacts(dir, { version: "test-v1" }),
/不能覆盖/,
);
assert.throws(
() => prepareArtifacts(dir, { version: "../outside" }),
/版本格式/,
);
});
test("公开指针不能重新指向原 CDN 或其他仓库", (t) => {
const { dir } = fixture(t);
prepareArtifacts(dir, { version: "test-v1" });
const file = path.join(dir, "latest.json");
const pointer = JSON.parse(fs.readFileSync(file));
pointer.catalogUrl =
"https://aliyuncdn.51easyai.com/model-catalog/official/v1/catalog.json";
fs.writeFileSync(file, JSON.stringify(pointer));
assert.throws(() => checkArtifacts(dir), /easyai 仓库公开地址/);
});
test("恢复 Git 历史定义后生成新回退版本,保留全部快照", (t) => {
const { dir, data, save } = fixture(t);
const original = structuredClone(data);
const first = prepareArtifacts(dir, { version: "test-v1" });
data.baseModels[0].displayName = "新版";
save();
const second = prepareArtifacts(dir, {
previous: first.release,
version: "test-v2",
});
fs.writeFileSync(
path.join(dir, "catalog.source.json"),
JSON.stringify(original, null, 2) + "\n",
);
const third = prepareArtifacts(dir, {
previous: second.release,
version: "test-v3",
note: "恢复 test-v1 的定义",
});
assert.equal(third.release.previousVersion, "test-v2");
assert.equal(third.release.checksum, first.release.checksum);
assert.equal(checkArtifacts(dir).version, "test-v3");
assert.deepEqual(fs.readdirSync(path.join(dir, "releases")).sort(), [
"test-v1",
"test-v2",
"test-v3",
]);
});
+7
View File
@@ -0,0 +1,7 @@
{
"schemaVersion": 1,
"version": "20261015T034833896Z",
"checksum": "b196b44afb78ff349cc1384d0fb97d1c7d33198e8f95bbe0bd6e075dd8354e98",
"publishedAt": "2026-10-15T03:48:33.896Z",
"catalogUrl": "https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/releases/20261015T034833896Z/catalog.json"
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "easyai-official-model-catalog",
"private": true,
"version": "1.0.0",
"type": "module",
"packageManager": "yarn@1.22.22",
"engines": {
"node": ">=22.22.0"
},
"scripts": {
"format": "node catalog.mjs format",
"validate": "node catalog.mjs validate",
"prepare-release": "node catalog.mjs prepare",
"check": "node catalog.mjs check",
"test": "node --test catalog.test.mjs"
},
"dependencies": {
"ajv": "8.17.1"
}
}
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
ajv@8.17.1:
version "8.17.1"
resolved "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6"
integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==
dependencies:
fast-deep-equal "^3.1.3"
fast-uri "^3.0.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-uri@^3.0.1:
version "3.1.8"
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz#f7db8d942e20eead3cfe93b0beeb4eb0169e938b"
integrity sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==
json-schema-traverse@^1.0.0:
version "1.0.0"
resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
require-from-string@^2.0.2:
version "2.0.2"
resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
@@ -0,0 +1,69 @@
function Get-ApplicationVersionPlan {
param([string]$Path)
$content = Get-Content $Path -Raw -Encoding UTF8
$selection = [string]$env:UPDATE_SERVICES
$selected = @($selection.Trim() -split '\s+' | Where-Object { $_ })
$mapping = @(
@('easyai-server','SERVER_VERSION','UPDATE_SERVER_VERSION','registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server'),
@('easyai-web','WEB_VERSION','UPDATE_WEB_VERSION','registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai'),
@('ws-gateway','WS_GATEWAY_VERSION','UPDATE_GATEWAY_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway'),
@('model-conversion-worker','MODEL_CONVERSION_WORKER_VERSION','UPDATE_WORKER_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker'),
@('easyai-updater','UPDATER_VERSION','UPDATE_UPDATER_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater'),
@('easyai-asg','ASG_VERSION','UPDATE_ASG_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance'),
@('agent-memory','AMS_VERSION','UPDATE_AMS_VERSION','registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory')
)
$plan = @()
foreach ($item in $mapping) {
$service,$key,$option,$image = $item
$value = [Environment]::GetEnvironmentVariable($option)
$isSelected = $selected.Count -eq 0 -or $service -in $selected
if ($value -and -not $isSelected) { throw "$option 要求在 UPDATE_SERVICES 中指定 $service" }
if (-not $isSelected) { continue }
if (-not $value) { $value = $env:UPDATE_RELEASE_VERSION }
if (-not $value) { $value = Get-EnvValue $content $key }
if (-not $value) { $value = Get-EnvValue $content 'VERSION' }
$pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$'
if ($service -eq 'easyai-web') { $pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?(-no-ssr|-hmg-ai|-easyai|-chinacuc)?$' }
if (-not $value -or $value -notmatch $pattern) { throw "$service 缺少固定版本;请指定 $option / UPDATE_RELEASE_VERSION,latest/dev 等浮动标签不能用于应用更新" }
$plan += [PSCustomObject]@{ Service=$service; Key=$key; Version=$value; Image=$image }
}
if ($env:UPDATE_RELEASE_VERSION -and ($selected.Count -eq 0 -or ('easyai-server' -in $selected -and 'easyai-web' -in $selected))) {
$oldDefault = Get-EnvValue $content 'VERSION'
foreach ($item in $mapping) {
$service,$key,$option,$image = $item
if ($selected.Count -gt 0 -and $service -notin $selected -and -not (Get-EnvValue $content $key) -and $oldDefault) {
$plan += [PSCustomObject]@{ Service=''; Key=$key; Version=$oldDefault; Image='' }
}
}
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$env:UPDATE_RELEASE_VERSION; Image='' }
}
return $plan
}
function Set-ApplicationVersions {
param([string]$Path, [object[]]$Plan)
$content = Get-Content $Path -Raw -Encoding UTF8
foreach ($item in $Plan) { $content = Set-EnvValue $content $item.Key $item.Version }
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
foreach ($item in $Plan) { Write-Host "📌 已持久化应用版本: $($item.Key)=$($item.Version)" }
}
function Test-ApplicationVersionImages {
param([object[]]$Plan, [string[]]$ComposeCommand)
foreach ($item in $Plan) {
if (-not $item.Service) { continue }
$args = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json',$item.Service)
$executable = $ComposeCommand[0]
$configuration = & $executable @args
if ($LASTEXITCODE -ne 0) { throw '无法读取应用的有效镜像定义;尚未拉取或重建服务' }
try {
# Compose includes dependencies even when a service is selected.
# Keep the configuration in memory; do not log its environment.
$resolved = ($configuration -join "`n") | ConvertFrom-Json -ErrorAction Stop
$actual = $resolved.services.PSObject.Properties[$item.Service].Value.image
} catch { throw '无法读取应用的有效镜像定义;尚未拉取或重建服务' }
if ("$actual".Trim() -ne "$($item.Image):$($item.Version)") {
throw "$($item.Service) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务"
}
}
}
+50
View File
@@ -55,6 +55,48 @@ function Initialize-SecurityEnv {
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -eq $content) { $content = "" }
$active = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID'
$ringText = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$legacy = Get-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT'
if ($legacy -and $legacy -notin @('true','false')) { throw 'Credential legacy flag must be true/false' }
if (-not $active -and -not $ringText) {
if ($Mode -ne 'new' -and $legacy -eq 'false') { throw 'Credential encryption key missing; restore the original configuration' }
if ($Mode -eq 'new' -or $env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION -eq 'true') {
$bytes = New-Object byte[] 32
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $rng.GetBytes($bytes) } finally { $rng.Dispose() }
$active = 'v1'
$ringText = '{"v1":"' + [Convert]::ToBase64String($bytes) + '"}'
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' $active
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' ("'" + $ringText + "'")
Write-Host ' [OK] Initialized independent credential encryption key (value omitted)'
} elseif ($legacy -eq 'false') {
throw 'Credential encryption key missing; restore the original configuration'
} else {
Write-Host ' [INFO] Existing deployment retains legacy compatibility; opt in using DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true'
}
}
if ($active -or $ringText) {
try {
if ($active -notmatch '^[A-Za-z0-9_-]{1,64}$') { throw 'invalid' }
$normalized = $ringText -replace '\s', ''
if ($ringText -notmatch '^\{\s*("[A-Za-z0-9_-]{1,64}"\s*:\s*"[A-Za-z0-9+/]{43}="\s*,\s*)*"[A-Za-z0-9_-]{1,64}"\s*:\s*"[A-Za-z0-9+/]{43}="\s*\}$') { throw 'invalid' }
$ids = New-Object 'System.Collections.Generic.Dictionary[string,bool]' ([System.StringComparer]::Ordinal)
foreach ($match in [regex]::Matches($normalized, '"([A-Za-z0-9_-]+)":"([A-Za-z0-9+/]+=)"')) {
$id = $match.Groups[1].Value; $encoded = $match.Groups[2].Value
if ($ids.ContainsKey($id)) { throw 'duplicate' }
$key = [Convert]::FromBase64String($encoded)
if ($key.Length -ne 32 -or [Convert]::ToBase64String($key) -cne $encoded) { throw 'invalid' }
$ids[$id] = $true
}
if (-not $ids.ContainsKey($active)) { throw 'invalid' }
} catch { throw 'Credential keyring invalid or incomplete; restore original keys (values omitted)' }
if (-not $legacy) {
if ($Mode -eq 'new') { $legacy = 'false' } else { $legacy = 'true' }
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' $legacy
}
if ($legacy -notin @('true', 'false')) { throw 'Credential legacy flag must be true/false' }
}
$definitions = @(
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 },
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
@@ -102,4 +144,12 @@ function Initialize-SecurityEnv {
$content,
[System.Text.UTF8Encoding]::new($false)
)
if ($env:OS -eq 'Windows_NT') {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -LiteralPath $Path -AclObject $acl
}
}
+68
View File
@@ -0,0 +1,68 @@
#Requires -Version 5.1
param(
[Parameter(Mandatory=$true)][ValidateSet('export','restore')][string]$Mode,
[Parameter(Mandatory=$true)][string]$Path,
[string]$EnvPath = (Join-Path (Split-Path -Parent $PSScriptRoot) '.env')
)
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1')
function Protect-CredentialFile([string]$FilePath) {
if ($env:OS -eq 'Windows_NT') {
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.FileSecurity
$acl.SetAccessRuleProtection($true, $false)
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')))
Set-Acl -LiteralPath $FilePath -AclObject $acl
} else { & chmod 600 $FilePath; if ($LASTEXITCODE) { throw 'Cannot protect credential file' } }
}
if (-not (Test-Path -LiteralPath $EnvPath -PathType Leaf)) { throw 'Prepare the deployment .env first' }
$EnvPath = [System.IO.Path]::GetFullPath($EnvPath)
$Path = [System.IO.Path]::GetFullPath($Path)
if ($EnvPath -eq $Path) { throw 'Keep the key backup separate from deployment configuration' }
$source = $EnvPath
if ($Mode -eq 'restore') { $source = $Path }
$content = Get-Content -LiteralPath $source -Raw -Encoding UTF8
$active = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID'
$ring = Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$legacy = Get-EnvValue $content 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT'
if (-not $active -or -not $ring) { throw 'Incomplete original keyring; no replacement key will be generated' }
if (-not $legacy) { $legacy = 'false' }
$keyContent = "CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=$active`nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING='$ring'`nCONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=$legacy`n"
foreach ($name in @('CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) {
$match = [regex]::Match($content, "(?m)^$name=(.*)$")
if ($match.Success) { $keyContent += $match.Value.TrimEnd("`r") + "`n" }
elseif ($Mode -eq 'export') { $keyContent += "$name=`n" }
}
$tmp = $EnvPath + '.credential-keys.' + [Guid]::NewGuid().ToString('N')
try {
[System.IO.File]::WriteAllText($tmp, '', (New-Object System.Text.UTF8Encoding($false)))
Protect-CredentialFile $tmp
[System.IO.File]::WriteAllText($tmp, $keyContent, (New-Object System.Text.UTF8Encoding($false)))
Initialize-SecurityEnv -Path $tmp -Mode upgrade *> $null
if ($Mode -eq 'export') {
$reservation = [System.IO.File]::Open($Path, 'CreateNew', 'Write', 'None'); $reservation.Dispose()
Protect-CredentialFile $Path
[System.IO.File]::WriteAllText($Path, $keyContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host 'Exported full keyring, including old keys. Store privately and separately from database backups.'
} else {
$original = Get-Content -LiteralPath $EnvPath -Raw -Encoding UTF8
$previous = $EnvPath + '.pre-credential-restore.' + [Guid]::NewGuid().ToString('N')
[System.IO.File]::WriteAllText($previous, '', (New-Object System.Text.UTF8Encoding($false)))
Protect-CredentialFile $previous
[System.IO.File]::WriteAllText($previous, $original, (New-Object System.Text.UTF8Encoding($false)))
foreach ($name in @('CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID','CONFIG_CREDENTIAL_ENCRYPTION_KEYRING','CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT','CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) {
$saved = [regex]::Match($keyContent, "(?m)^$name=(.*)$")
if (-not $saved.Success) { continue }
$literalLine = $saved.Value.TrimEnd("`r") + "`r`n"
$pattern = "(?m)^$name=.*(?:\r?\n)?"
if ([regex]::IsMatch($original, $pattern)) {
$evaluator = [System.Text.RegularExpressions.MatchEvaluator]{ param($match) return $literalLine }.GetNewClosure()
$original = [regex]::Replace($original, $pattern, $evaluator)
} else { $original = $original.TrimEnd("`r","`n") + "`r`n" + $literalLine }
}
[System.IO.File]::WriteAllText($tmp, $original, (New-Object System.Text.UTF8Encoding($false)))
Move-Item -LiteralPath $tmp -Destination $EnvPath -Force
Protect-CredentialFile $EnvPath
Write-Host 'Restored original keyring; other configuration preserved. Recreate all related containers before database restore.'
}
} finally { if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force } }
+28
View File
@@ -0,0 +1,28 @@
#Requires -Version 5.1
param(
[ValidateSet('inventory','dry-run','apply','verify','finalize')][string]$Mode = 'inventory',
[Parameter(Mandatory=$true)][string]$ExpectedTarget
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $root
. (Join-Path $root 'scripts/Initialize-SecurityEnv.ps1')
Initialize-SecurityEnv -Path (Join-Path $root '.env') -Mode upgrade
$content = Get-Content (Join-Path $root '.env') -Raw
if (-not (Get-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING')) { throw 'Configure independent encryption keys and prepare database/queue backup before migrating' }
$checkpointDir = Join-Path $root 'data/credential-migration'
[System.IO.Directory]::CreateDirectory($checkpointDir) | Out-Null
if ($env:OS -eq 'Windows_NT') {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
$acl.AddAccessRule($rule)
Set-Acl -LiteralPath $checkpointDir -AclObject $acl
}
$toolArgs = @("--mode=$Mode", '--production=true', "--expected-target=$ExpectedTarget", '--batch-size=100', '--checkpoint=/credential-migration/checkpoint.json')
if ($Mode -eq 'finalize') { $toolArgs = @('--mode=verify', '--finalize=true', '--production=true', "--expected-target=$ExpectedTarget", '--batch-size=100') }
docker compose version *> $null
if ($LASTEXITCODE -eq 0) { docker compose run --rm --no-deps -v "${checkpointDir}:/credential-migration" easyai-server node /app/dist/credential-migrate.js @toolArgs }
else { docker-compose run --rm --no-deps -v "${checkpointDir}:/credential-migration" easyai-server node /app/dist/credential-migrate.js @toolArgs }
if ($LASTEXITCODE -ne 0) { throw 'Credential migration failed; preserve checkpoint and resolve configuration or concurrency conflicts' }
+50
View File
@@ -0,0 +1,50 @@
#Requires -Version 5.1
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'Initialize-SecurityEnv.ps1')
$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('easyai-key-recovery-' + [Guid]::NewGuid().ToString('N'))
[System.IO.Directory]::CreateDirectory($temp) | Out-Null
$envFile = Join-Path $temp '.env'; $keyFile = Join-Path $temp 'keys.env'; $logFile = Join-Path $temp 'result.log'
function Assert-KeyTest([bool]$Value, [string]$Label) { if (-not $Value) { throw "FAILED: $Label" } }
try {
[System.IO.File]::WriteAllText($envFile, '')
Initialize-SecurityEnv -Path $envFile -Mode new *> $logFile
$original = Get-Content $envFile -Raw
$ring = Get-EnvValue $original 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$second = [Convert]::FromBase64String((New-SecuritySecret))
$second = [Convert]::ToBase64String($second[0..31])
$multi = $ring.Substring(0, $ring.Length - 1) + ',"v2":"' + $second + '"}'
$content = Set-EnvValue $original 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' ("'" + $multi + "'")
$content = Set-EnvValue $content 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID' 'v2'
$content = Set-EnvValue $content 'UNRELATED_CONFIG' 'keep-me'
[System.IO.File]::WriteAllText($envFile, $content)
& (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode export -Path $keyFile -EnvPath $envFile *>> $logFile
Assert-KeyTest ((Get-EnvValue (Get-Content $keyFile -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -ceq $multi) 'full ring exported'
$refused = $false
try { & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode export -Path $keyFile -EnvPath $envFile *>> $logFile } catch { $refused = $true }
Assert-KeyTest $refused 'existing backup never overwritten'
[System.IO.File]::WriteAllText($envFile, (Set-EnvValue $original 'UNRELATED_CONFIG' 'keep-me'))
& (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode restore -Path $keyFile -EnvPath $envFile *>> $logFile
$restored = Get-Content $envFile -Raw
foreach ($name in @('CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY','CONFIG_JWT_SECRET')) { Assert-KeyTest ((Get-EnvValue $restored $name) -ceq (Get-EnvValue $original $name)) 'original config master and JWT retained' }
Assert-KeyTest ((Get-EnvValue $restored 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -ceq $multi) 'both original keys restored'
Assert-KeyTest ((Get-EnvValue $restored 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID') -ceq 'v2') 'active key restored'
Assert-KeyTest ((Get-EnvValue $restored 'UNRELATED_CONFIG') -ceq 'keep-me') 'unrelated config preserved'
$backups = @(Get-ChildItem $temp -Force -Filter '.env.pre-credential-restore.*')
Assert-KeyTest ($backups.Count -eq 1) 'previous env saved'
$bad = Join-Path $temp 'bad.env'
[System.IO.File]::WriteAllText($bad, "CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v2`nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING='{}'`n")
$refused = $false
try { & (Join-Path $PSScriptRoot 'Manage-CredentialKeys.ps1') -Mode restore -Path $bad -EnvPath $envFile *>> $logFile } catch { $refused = $true }
Assert-KeyTest $refused 'invalid recovery refused'
Assert-KeyTest ((Get-Content $envFile -Raw) -ceq $restored) 'invalid recovery never mutates env'
Assert-KeyTest (-not (Get-Content $logFile -Raw).Contains($second)) 'no key leaked to log'
if ($env:OS -eq 'Windows_NT') {
$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
foreach ($file in @($envFile, $keyFile, $backups[0].FullName)) {
$acl = Get-Acl -LiteralPath $file
Assert-KeyTest $acl.AreAccessRulesProtected 'private DACL'
foreach ($rule in $acl.Access) { Assert-KeyTest ($rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value -eq $sid) 'only current user access' }
}
}
Write-Host 'Credential key backup/restore self-test passed.'
} finally { Remove-Item -LiteralPath $temp -Recurse -Force }
+20
View File
@@ -80,6 +80,26 @@ try {
Assert-MinLength $freshContent "CONFIG_INITIAL_ADMIN_PASSWORD" 12
Assert-Value $freshContent "WS_AUTH_METHODS" "none,bearer,ws_ticket"
$freshRing = Get-EnvValue $freshContent 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
$freshId = Get-EnvValue $freshContent 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID'
$parsedRing = $freshRing | ConvertFrom-Json
if ([Convert]::FromBase64String($parsedRing.$freshId).Length -ne 32) { throw 'Invalid credential key length' }
Assert-Value $freshContent 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' 'false'
Initialize-SecurityEnv -Path $fresh -Mode upgrade
if ((Get-EnvValue (Get-Content $fresh -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') -cne $freshRing) { throw 'Credential key must survive repeat upgrade' }
if (Get-EnvValue $upgradeContent 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING') { throw 'Existing deployment must not silently opt in' }
$env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = 'true'
Initialize-SecurityEnv -Path $upgrade -Mode upgrade
$env:DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION = $null
$opted = Get-Content $upgrade -Raw
Assert-Value $opted 'CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT' 'true'
$before = Get-EnvValue $opted 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING'
Initialize-SecurityEnv -Path $upgrade -Mode upgrade
Assert-Value (Get-Content $upgrade -Raw) 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING' $before
$bad = Write-TestEnv 'bad.env' @('CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v1', 'CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=')
$rejected = $false
try { Initialize-SecurityEnv -Path $bad -Mode upgrade } catch { $rejected = $true }
if (-not $rejected) { throw 'Partial key configuration must fail' }
Write-Host "Security environment PowerShell tests passed" -ForegroundColor Green
} finally {
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
+50
View File
@@ -0,0 +1,50 @@
#!/bin/bash
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-security-env.sh
. "$SCRIPT_DIR/init-security-env.sh"
mode="${1:-}"; keyfile="${2:-}"; envfile="${3:-$SCRIPT_DIR/../.env}"
case "$mode" in export|restore) ;; *) echo '用法: credential-keys.sh export|restore 私有密钥备份文件 [部署.env]' >&2; exit 1 ;; esac
[ -n "$keyfile" ] && [ -f "$envfile" ] || { echo '请指定密钥备份文件,并先准备部署 .env' >&2; exit 1; }
[ "$keyfile" != "$envfile" ] || { echo '密钥备份必须与部署配置分开' >&2; exit 1; }
sourcefile="$envfile"
if [ "$mode" = restore ]; then sourcefile="$keyfile"; fi
[ -f "$sourcefile" ] || { echo '密钥备份不存在' >&2; exit 1; }
active="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)"
ring="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
legacy="$(read_env_value "$sourcefile" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT)"
[ -n "$active" ] && [ -n "$ring" ] || { echo '没有完整加密密钥,不能导出或恢复;不会生成替代密钥' >&2; exit 1; }
tmp="$(mktemp "${envfile}.credential-keys.XXXXXX")"
trap 'rm -f "$tmp"' EXIT
printf 'CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=%s\nCONFIG_CREDENTIAL_ENCRYPTION_KEYRING=%s\nCONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=%s\n' "$active" "'$ring'" "${legacy:-false}" > "$tmp"
init_credential_encryption_env "$tmp" upgrade >/dev/null
# Preserve the independent master and JWT fallback used by earlier encrypted settings.
for name in CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY CONFIG_JWT_SECRET; do
if [ "$mode" = export ]; then
awk -v key="$name" 'index($0, key "=") == 1 { print; found=1; exit } END { if (!found) print key "=" }' "$sourcefile" >> "$tmp"
else
awk -v key="$name" 'index($0, key "=") == 1 { print; exit }' "$sourcefile" >> "$tmp"
fi
done
if [ "$mode" = export ]; then
# noclobber reserves an immutable backup instead of truncating an earlier keyring.
( set -o noclobber; cat "$tmp" > "$keyfile" ) || { echo '密钥备份已存在或路径不可写,请使用新文件' >&2; exit 1; }
chmod 600 "$keyfile"
echo '已导出完整 keyring(含旧密钥);请与数据库备份分开私有保管。'
else
previous="${envfile}.pre-credential-restore.$(date +%Y%m%dT%H%M%S).$$"
cp "$envfile" "$previous"; chmod 600 "$previous"
key_content="$(cat "$tmp")"
awk 'index($0,"CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=")==1 || index($0,"CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=")==1 || index($0,"CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT=")==1 { next } { print }' "$envfile" > "$tmp"
for name in CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY CONFIG_JWT_SECRET; do
if printf '%s\n' "$key_content" | awk -v key="$name" 'index($0,key "=")==1 { found=1 } END { exit !found }'; then
filtered="$(mktemp "${envfile}.filtered.XXXXXX")"
awk -v key="$name" 'index($0,key "=")!=1 { print }' "$tmp" > "$filtered"
mv "$filtered" "$tmp"
fi
done
printf '%s\n' "$key_content" >> "$tmp"
chmod 600 "$tmp"; mv "$tmp" "$envfile"
echo '已恢复原 keyring,其他配置保留;重建全部相关容器后再恢复数据库。'
fi
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# 仅规划/持久化已选应用的固定版本;由 update.sh 调用,不编辑 Compose。
APPLICATION_VERSION_MAP='easyai-server SERVER_VERSION UPDATE_SERVER_VERSION registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server
easyai-web WEB_VERSION UPDATE_WEB_VERSION registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai
ws-gateway WS_GATEWAY_VERSION UPDATE_GATEWAY_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway
model-conversion-worker MODEL_CONVERSION_WORKER_VERSION UPDATE_WORKER_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker
easyai-updater UPDATER_VERSION UPDATE_UPDATER_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater
easyai-asg ASG_VERSION UPDATE_ASG_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance
agent-memory AMS_VERSION UPDATE_AMS_VERSION registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory'
application_selected() {
[ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" $1 "* ]]
}
plan_application_versions() {
local file="$1" service key option image value suffix base old_default
APPLICATION_VERSION_WRITES=()
while read -r service key option image; do
value="${!option:-}"
if [ -n "$value" ] && ! application_selected "$service"; then
echo "❌ $option 要求在 UPDATE_SERVICES 中指定 $service;尚未修改环境或容器" >&2
return 1
fi
application_selected "$service" || continue
if [ -z "$value" ] && [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then value="$UPDATE_RELEASE_VERSION"; fi
if [ -z "$value" ]; then value="$(read_env_value "$file" "$key")"; fi
if [ -z "$value" ]; then value="$(read_env_value "$file" VERSION)"; fi
suffix=''
if [ "$service" = easyai-web ]; then suffix='(-no-ssr|-hmg-ai|-easyai|-chinacuc)?'; fi
if ! [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?${suffix}$ ]]; then
echo "❌ $service 缺少固定版本:请指定 $option 或 UPDATE_RELEASE_VERSION,或在 .env 保留有效 $key;latest/dev 等浮动标签不能用于应用更新" >&2
return 1
fi
APPLICATION_VERSION_WRITES+=("$key=$value")
done <<< "$APPLICATION_VERSION_MAP"
if [ -n "${UPDATE_RELEASE_VERSION:-}" ] && application_selected easyai-server && application_selected easyai-web; then
# VERSION 仍保留兼容入口;改变它前先冻结未选中服务的旧继承值。
old_default="$(read_env_value "$file" VERSION)"
while read -r service key option image; do
application_selected "$service" && continue
value="$(read_env_value "$file" "$key")"
if [ -z "$value" ] && [ -n "$old_default" ]; then APPLICATION_VERSION_WRITES+=("$key=$old_default"); fi
done <<< "$APPLICATION_VERSION_MAP"
APPLICATION_VERSION_WRITES+=("VERSION=$UPDATE_RELEASE_VERSION")
fi
}
apply_application_versions() {
local file="$1" entry temporary
temporary="$(mktemp "${file}.versions.XXXXXX")"
cp "$file" "$temporary"
chmod 600 "$temporary"
for entry in "${APPLICATION_VERSION_WRITES[@]}"; do
write_env_value "$temporary" "${entry%%=*}" "${entry#*=}"
done
mv "$temporary" "$file"
for entry in "${APPLICATION_VERSION_WRITES[@]}"; do echo "📌 已持久化应用版本: $entry"; done
}
verify_application_version_images() {
local file="$1" compose="$2" service key option image version actual configuration
while read -r service key option image; do
application_selected "$service" || continue
version="$(read_env_value "$file" "$key")"
# --images SERVICE includes dependency images on Compose 5. Read the
# selected service from normalized config instead of comparing the list.
configuration="$(sudo $compose config --format json "$service")" || return 1
actual="$(printf '%s' "$configuration" | python3 -c '
import json, sys
try:
image = json.load(sys.stdin)["services"][sys.argv[1]]["image"]
if not isinstance(image, str): raise ValueError()
print(image)
except (ValueError, KeyError, TypeError):
raise SystemExit("无法读取应用的有效镜像定义;尚未拉取或重建服务")
' "$service")" || return 1
if [ "$actual" != "$image:$version" ]; then
echo "❌ $service 的有效镜像未使用 .env 中的固定版本 $key=$version;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务" >&2
return 1
fi
done <<< "$APPLICATION_VERSION_MAP"
}
+56
View File
@@ -115,6 +115,61 @@ ensure_initial_admin_password() {
echo " ✓ 已初始化管理员密码: CONFIG_INITIAL_ADMIN_PASSWORD"
}
init_credential_encryption_env() {
local file="$1" mode="$2" active ring normalized entry id encoded decoded reencoded ids='' legacy
active="$(read_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)"
ring="$(read_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
legacy="$(read_env_value "$file" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT)"
case "$legacy" in ''|true|false) ;; *) echo '❌ 旧凭据兼容开关只能为 true/false' >&2; return 1 ;; esac
if [ -z "$active" ] && [ -z "$ring" ]; then
if [ "$mode" != new ] && [ "$legacy" = false ]; then
echo '❌ 已关闭旧凭据兼容但缺少加密密钥,请恢复原配置' >&2
return 1
fi
# Existing sites opt in explicitly: never invent a replacement after key loss.
if [ "$mode" != new ] && [ "${DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION:-false}" != true ]; then
if [ "$legacy" = false ]; then
echo '❌ 已关闭旧凭据兼容但缺少加密密钥,请恢复原配置' >&2
return 1
fi
echo ' ℹ 凭据加密尚未接入,保留旧部署兼容;首次接入使用 DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true'
return 0
fi
command -v openssl >/dev/null 2>&1 || { echo '❌ 凭据加密初始化需要 openssl' >&2; return 1; }
active=v1
encoded="$(openssl rand -base64 32 | tr -d '\r\n')" || return 1
ring="{\"${active}\":\"${encoded}\"}"
write_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID "$active"
write_env_value "$file" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$ring'"
echo ' ✓ 已初始化独立凭据加密密钥(未输出密钥)'
fi
# Values have a restricted JSON grammar; no eval/source or secret command arguments.
normalized="$(printf '%s' "$ring" | tr -d ' \t\r\n')"
if ! [[ "$active" =~ ^[A-Za-z0-9_-]{1,64}$ ]] ||
! printf '%s' "$ring" | LC_ALL=C grep -Eq '^\{[[:space:]]*("[A-Za-z0-9_-]{1,64}"[[:space:]]*:[[:space:]]*"[A-Za-z0-9+/]{43}="[[:space:]]*,[[:space:]]*)*"[A-Za-z0-9_-]{1,64}"[[:space:]]*:[[:space:]]*"[A-Za-z0-9+/]{43}="[[:space:]]*\}$'; then
echo '❌ 凭据加密配置不完整或无效,请恢复原密钥;不会自动替换' >&2
return 1
fi
command -v openssl >/dev/null 2>&1 || { echo '❌ 凭据密钥校验需要 openssl' >&2; return 1; }
while IFS= read -r entry; do
id="${entry%%:*}"; id="${id#\"}"; id="${id%\"}"
encoded="${entry#*:}"; encoded="${encoded#\"}"; encoded="${encoded%\"}"
case ",$ids," in *",$id,"*) echo '❌ 凭据密钥 ID 重复' >&2; return 1 ;; esac
ids="${ids:+$ids,}$id"
decoded="$(printf '%s' "$encoded" | openssl base64 -d -A | wc -c | tr -d ' ')"
reencoded="$(printf '%s' "$encoded" | openssl base64 -d -A | openssl base64 -A)"
if [ "$decoded" != 32 ] || [ "$reencoded" != "$encoded" ]; then
echo '❌ 凭据密钥必须为标准 Base64 编码的 32 字节随机值' >&2; return 1
fi
done < <(printf '%s\n' "$normalized" | sed 's/^{//;s/}$//' | tr ',' '\n')
case ",$ids," in *",$active,"*) ;; *) echo '❌ 当前凭据密钥 ID 不在 keyring 中' >&2; return 1 ;; esac
if [ -z "$legacy" ]; then
if [ "$mode" = new ]; then legacy=false; else legacy=true; fi
write_env_value "$file" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT "$legacy"
fi
case "$legacy" in true|false) ;; *) echo '❌ 旧凭据兼容开关只能为 true/false' >&2; return 1 ;; esac
}
init_security_env() {
local file="${1:-.env}"
local mode="${2:-upgrade}"
@@ -127,6 +182,7 @@ init_security_env() {
return 1
fi
init_credential_encryption_env "$file" "$mode" || return 1
ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret"
ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET"
ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN"
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${SCRIPT_DIR}/.."
mode="${1:-inventory}"
expected="${2:-}"
case "$mode" in inventory|dry-run|apply|verify|finalize) ;; *) echo '用法: scripts/migrate-credential-encryption.sh inventory|dry-run|apply|verify|finalize 主机/数据库' >&2; exit 1 ;; esac
[ -n "$expected" ] || { echo '需要明确指定容器内 MongoDB 主机/数据库,例如 172.21.0.3/aidraw' >&2; exit 1; }
[ -f .env ] || { echo '未找到部署 .env' >&2; exit 1; }
# shellcheck source=scripts/init-security-env.sh
. "$SCRIPT_DIR/init-security-env.sh"
init_credential_encryption_env .env upgrade
[ -n "$(read_env_value .env CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" ] || { echo '请先接入独立加密密钥,完成备份并暂停相关写入/消费者' >&2; exit 1; }
mkdir -p data/credential-migration
chmod 700 data/credential-migration
compose=(docker compose)
if ! docker compose version >/dev/null 2>&1; then compose=(docker-compose); fi
args=("--mode=$mode" --production=true "--expected-target=$expected" --batch-size=100 --checkpoint=/credential-migration/checkpoint.json)
if [ "$mode" = finalize ]; then args=(--mode=verify --finalize=true --production=true "--expected-target=$expected" --batch-size=100); fi
# Credentials come exclusively from the existing service environment.
"${compose[@]}" run --rm --no-deps -v "$PWD/data/credential-migration:/credential-migration" easyai-server node /app/dist/credential-migrate.js "${args[@]}"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const VERSION = /^\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?$/;
const TARGETS = {
'hmg-ai': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-hmg-ai','-hmg-ai'],
easyai: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-easyai','-easyai'],
chinacuc: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-chinacuc','-chinacuc'],
'server-main': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server','latest',''],
web: ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest',''],
'web-no-ssr': ['registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai','latest-no-ssr','-no-ssr'],
'ws-gateway': ['registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway','latest',''],
'model-conversion-worker': ['registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker','latest',''],
updater: ['registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater','latest',''],
'service-governance': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance','latest',''],
'memory-service': ['registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory','latest',''],
};
const CORE = ['server-main','web','ws-gateway'];
const docker = args => execFileSync('docker', args, {encoding:'utf8', stdio:['ignore','pipe','pipe']});
const inspectArgs = ref => ['buildx','imagetools','inspect',ref,'--format','{{json .Manifest}}'];
export function promotionPlan(manifest, version, confirmedVersion) {
if (!VERSION.test(version ?? '') || version !== confirmedVersion) throw new Error('必须人工指定完整 --version 并用相同 --confirm-version 确认,不能自动选择最新版');
if (manifest.schemaVersion !== 1 || manifest.channel !== 'versioned' || manifest.productVersion !== version || manifest.platform !== 'linux/amd64') throw new Error('候选版本记录与指定版本/平台不一致');
if (typeof manifest.releaseId !== 'string' || !manifest.releaseId.trim() || manifest.releaseId.length > 200 || !Array.isArray(manifest.components)) throw new Error('候选版本记录缺少有效 releaseId/components');
if (!Number.isFinite(Date.parse(manifest.publishedAt)) || ['managedUpdateAllowed','scheduledUpdateAllowed','rollbackSafe'].some(key=>typeof manifest[key]!=='boolean')) throw new Error('候选版本记录缺少有效发布时间或更新兼容性设置');
const seen = new Set();
const images = manifest.components.map(component => {
const target = TARGETS[component.key];
if (!target || seen.has(component.key) || component.image !== target[0] || !/^sha256:[a-f0-9]{64}$/.test(component.digest ?? '')) throw new Error(`无效或重复组件: ${component.key}`);
seen.add(component.key);
const tag = component.tag ?? `${version}${target[2]}`;
const base = target[2] && tag.endsWith(target[2]) ? tag.slice(0,-target[2].length) : tag;
if (!VERSION.test(base) || (target[2] && !tag.endsWith(target[2]))) throw new Error(`组件 ${component.key} 必须引用固定版本标签`);
return {key:component.key, image:component.image, digest:component.digest, source:`${component.image}:${tag}`, stable:`${component.image}:${target[1]}`};
});
for (const key of [...CORE,'web-no-ssr']) if (!seen.has(key)) throw new Error(`候选记录缺少必要组件: ${key}`);
return images;
}
export function promoteStableRelease({manifest, version, confirmedVersion, outputPath, dryRun=false, run=docker}) {
const plan = promotionPlan(manifest, version, confirmedVersion);
// 所有固定标签先核验完成,再发生任何稳定标签写入。
for (const image of plan) {
const descriptor = JSON.parse(run(inspectArgs(image.source)));
let platforms = descriptor.manifests?.map(item=>item.platform) ?? [descriptor.platform];
if (!platforms.some(Boolean)) {
const verbose = JSON.parse(run(['manifest','inspect','--verbose',image.source]));
platforms = (Array.isArray(verbose) ? verbose : [verbose]).map(item=>item.Descriptor?.platform);
}
if (descriptor.digest !== image.digest || !platforms.some(platform=>platform?.os==='linux' && platform?.architecture==='amd64')) throw new Error(`固定产物核验失败: ${image.key};稳定标签未修改`);
}
if (dryRun) return {status:'DRY_RUN',version,images:plan};
if (!outputPath) throw new Error('缺少稳定清单输出路径');
const previous = new Map();
for (const image of plan) {
try { previous.set(image.stable, JSON.parse(run(inspectArgs(image.stable))).digest); }
catch (error) {
if (!/manifest unknown|not found|no such manifest/i.test(String(error.stderr ?? error.message))) throw error;
previous.set(image.stable,null);
}
}
const changed = [];
try {
for (const image of plan) {
// 先登记尝试;写入成功但回执丢失时也必须尝试恢复原稳定指针。
changed.push(image);
run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${image.digest}`]);
const actual = JSON.parse(run(inspectArgs(image.stable)));
if (actual.digest !== image.digest) throw new Error(`稳定标签读回不一致: ${image.key}`);
}
const promoted = {...manifest, channel:'stable', publishedAt:new Date().toISOString(),
promotedFromPublishedAt:manifest.publishedAt, components:manifest.components.filter(item=>CORE.includes(item.key))};
const temporary = `${outputPath}.tmp`;
writeFileSync(temporary, JSON.stringify(promoted,null,2)+'\n');
renameSync(temporary,outputPath);
return {status:'PROMOTED',version,images:plan,manifest:outputPath};
} catch (error) {
const recovery = [];
for (const image of changed.reverse()) {
const oldDigest = previous.get(image.stable);
if (!oldDigest) { recovery.push(`${image.key}: 原稳定标签不存在,保留本次标签并记录未完成`); continue; }
try {
run(['buildx','imagetools','create','--prefer-index=false','--tag',image.stable,`${image.image}@${oldDigest}`]);
if (JSON.parse(run(inspectArgs(image.stable))).digest !== oldDigest) throw new Error('恢复读回不一致');
recovery.push(`${image.key}: 已恢复原稳定标签`);
} catch { recovery.push(`${image.key}: 恢复失败,需按原 digest 继续恢复`); }
}
throw new Error(`稳定提升未完成,未提交新的稳定清单:${error.message};${recovery.join(';')}`);
}
}
function main() {
const options = {};
const args = process.argv.slice(2);
for (let index=0; index<args.length; index++) {
const key=args[index];
if (key==='--dry-run') options.dryRun=true;
else if (['--version','--confirm-version','--manifest','--output'].includes(key)) {
const value=args[++index];
if (!value || value.startsWith('--')) throw new Error(`${key} 缺少参数`);
options[key.slice(2)]=value;
} else throw new Error(`未知选项: ${key}`);
}
// 在读取候选文件、调用 Docker 前先拒绝未确认或浮动版本。
if (!VERSION.test(options.version ?? '') || options.version!==options['confirm-version']) throw new Error('用法: node scripts/promote-stable-release.mjs --version X.Y.Z.YYYYMMDD[.N] --confirm-version 同一版本 [--dry-run];仅在用户明确指定版本转稳定版后执行');
const manifestPath=resolve(options.manifest ?? `${ROOT}/system-update/releases/${options.version}.json`);
const manifest=JSON.parse(readFileSync(manifestPath,'utf8'));
console.log(JSON.stringify(promoteStableRelease({manifest,version:options.version,confirmedVersion:options['confirm-version'],
outputPath:resolve(options.output ?? `${ROOT}/system-update/release-manifest.json`),dryRun:options.dryRun}),null,2));
}
if (process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href) {
try { main(); } catch (error) { console.error(error.message); process.exitCode=1; }
}
+100
View File
@@ -0,0 +1,100 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promotionPlan, promoteStableRelease } from './promote-stable-release.mjs';
const version='3.29.0.20261014.2';
const manifest={schemaVersion:1,channel:'versioned',productVersion:version,platform:'linux/amd64',releaseId:'release-id',managedUpdateAllowed:true,scheduledUpdateAllowed:true,rollbackSafe:false,publishedAt:'2026-10-14T00:00:00Z',
components:[
['server-main','comfy-ai/comfy-server',''],['web','comfy-ai/one-ai',''],
['web-no-ssr','comfy-ai/one-ai','-no-ssr'],['ws-gateway','easyaigc/wsgateway',''],
['model-conversion-worker','easyaigc/model-conversion-worker',''],
].map(([key,image,suffix],index)=>({key,image:'registry.cn-shanghai.aliyuncs.com/'+image,tag:version+suffix,digest:'sha256:'+String(index+1).repeat(64),buildId:'source-sha'}))};
function fixture() {
const plan=promotionPlan(manifest,version,version);
const tags=new Map(plan.flatMap(image=>[[image.source,image.digest],[image.stable,'sha256:'+'9'.repeat(64)]]));
const calls=[];
const run=args=>{
calls.push(args);
if (args[0]==='manifest') return JSON.stringify({Descriptor:{platform:{os:'linux',architecture:'amd64'}}});
if (args[2]==='inspect') {
const ref=args[3];
// 真实单平台 imagetools 输出没有 platform,需读取 verbose manifest。
return JSON.stringify({digest:tags.get(ref),...(ref.includes('comfy-server') ? {} : {manifests:[{platform:{os:'linux',architecture:'amd64'}}]})});
}
assert.equal(args[2],'create');
assert.ok(args.includes('--prefer-index=false'));
const ref=args[args.indexOf('--tag')+1];
const digest=args.at(-1).split('@')[1];
tags.set(ref,digest);
return '';
};
return {plan,tags,calls,run};
}
test('未明确确认同一个版本及浮动版本都不调用Docker',()=>{
let called=false;
for (const [candidate,confirm] of [[version,undefined],[version,'3.29.0.20261014.3'],['latest','latest']]) {
assert.throws(()=>promoteStableRelease({manifest,version:candidate,confirmedVersion:confirm,run:()=>{called=true;}}),/人工指定/);
}
assert.equal(called,false);
});
test('候选必须包括SSR/noSSR,并拒绝重复、错误镜像和浮动源标签',()=>{
const variant = {...manifest.components[1],key:'hmg-ai',tag:version+'-hmg-ai'};
assert.equal(promotionPlan({...manifest,components:[...manifest.components,variant]},version,version).at(-1).stable,variant.image+':latest-hmg-ai');
for (const components of [manifest.components.filter(item=>item.key!=='web-no-ssr'),
[...manifest.components,manifest.components[0]],
manifest.components.map((item,index)=>index?item:{...item,image:'wrong/image'}),
manifest.components.map((item,index)=>index?item:{...item,tag:'latest'})]) {
assert.throws(()=>promotionPlan({...manifest,components},version,version));
}
});
test('全部固定产物先核验;任一digest不匹配时稳定标签和清单保持',()=>{
const f=fixture();
f.tags.set(f.plan.at(-1).source,'sha256:'+'8'.repeat(64));
assert.throws(()=>promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:'unused',run:f.run}),/核验失败/);
assert.ok(f.calls.every(args=>args[2]!=='create'));
assert.ok(f.plan.every(image=>f.tags.get(image.stable)==='sha256:'+'9'.repeat(64)));
});
test('dry-run核验固定版本且不写任何稳定标签或文件',()=>{
const f=fixture();
assert.equal(promoteStableRelease({manifest,version,confirmedVersion:version,dryRun:true,run:f.run}).status,'DRY_RUN');
assert.ok(f.calls.every(args=>args[2]!=='create'));
});
test('人工提升只按已核验digest补稳定标签,两种前端分别保留;不构建业务或改源版本',()=>{
const dir=mkdtempSync(join(tmpdir(),'easyai-promote-'));
try {
const path=join(dir,'stable.json');
const f=fixture();
const result=promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:path,run:f.run});
assert.equal(result.status,'PROMOTED');
assert.ok(f.plan.every(image=>f.tags.get(image.stable)===image.digest));
assert.ok(f.plan.every(image=>f.tags.get(image.source)===image.digest));
assert.equal(JSON.parse(readFileSync(path)).channel,'stable');
assert.deepEqual(JSON.parse(readFileSync(path)).components.map(item=>item.key),['server-main','web','ws-gateway']);
assert.ok(f.calls.every(args=>args[0]!=='build' && args[0]!=='push'));
} finally { rmSync(dir,{recursive:true,force:true}); }
});
test('稳定别名写入后回执失败时恢复原指针,稳定清单不提前写入',()=>{
const dir=mkdtempSync(join(tmpdir(),'easyai-promote-fail-'));
try {
const path=join(dir,'stable.json'); writeFileSync(path,'old stable manifest');
const f=fixture(); let once=true;
const run=args=>{
const output=f.run(args);
if (args[2]==='create' && args.includes(f.plan[2].stable) && once) { once=false; throw new Error('write succeeded but response lost'); }
return output;
};
assert.throws(()=>promoteStableRelease({manifest,version,confirmedVersion:version,outputPath:path,run}),/未完成.*已恢复/);
assert.ok(f.plan.every(image=>f.tags.get(image.stable)==='sha256:'+'9'.repeat(64)));
assert.equal(readFileSync(path,'utf8'),'old stable manifest');
} finally { rmSync(dir,{recursive:true,force:true}); }
});
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Exercise Bash and PowerShell image verification with real Compose config.
No images are pulled and no containers are created. Fixture settings are fake.
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import tempfile
import time
parser = argparse.ArgumentParser()
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parent.parent)
args = parser.parse_args()
source = args.source.resolve()
started = time.monotonic()
version = '1.0.0.20261014'
image = 'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server'
secret = 'synthetic-config-value-must-not-be-printed'
with tempfile.TemporaryDirectory(prefix='easyai-image-config-') as temporary:
root = Path(temporary)
(root / '.env').write_text(f'SERVER_VERSION={version}\nFIXTURE_SECRET={secret}\n')
binaries = root / 'bin'
binaries.mkdir()
sudo = binaries / 'sudo'
sudo.write_text('#!/bin/sh\nexec "$@"\n')
sudo.chmod(0o755)
env = {**os.environ, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'UPDATE_SERVICES': 'easyai-server'}
reports = []
for correct in (True, False):
selected = f'{image}:${{SERVER_VERSION}}' if correct else f'{image}:latest'
# On the negative case a dependency deliberately has the expected image;
# checking membership in --images output would wrongly approve it.
dependency = 'redis:7-alpine' if correct else f'{image}:${{SERVER_VERSION}}'
(root / 'compose.yml').write_text(f'services:\n dependency:\n image: {dependency}\n easyai-server:\n image: {selected}\n depends_on:\n - dependency\n environment:\n FIXTURE_SECRET: ${{FIXTURE_SECRET}}\n')
config = subprocess.run(['docker','compose','config','--format','json','easyai-server'], cwd=root, env=env, text=True, capture_output=True, check=True)
assert json.loads(config.stdout)['services']['easyai-server']['image'] == (f'{image}:{version}' if correct else f'{image}:latest')
images = subprocess.run(['docker','compose','config','--images','easyai-server'], cwd=root, env=env, text=True, capture_output=True, check=True)
assert len(images.stdout.splitlines()) > 1, 'Compose fixture must include dependency images'
bash = ['bash','-c', '. "$1/scripts/init-security-env.sh"; . "$1/scripts/init-application-version-env.sh"; verify_application_version_images .env "docker compose"', 'fixture', str(source)]
powershell = ['pwsh','-NoProfile','-NonInteractive','-Command', f". '{source}/scripts/Initialize-ApplicationVersions.ps1'; Test-ApplicationVersionImages @([pscustomobject]@{{Service='easyai-server';Image='{image}';Version='{version}'}}) @('docker','compose')"]
for name, command in [('bash', bash), ('powershell', powershell)]:
result = subprocess.run(command, cwd=root, env=env, text=True, errors='backslashreplace', capture_output=True, timeout=30)
assert (result.returncode == 0) == correct, f'{name} incorrect verification: {result.stdout} {result.stderr}'
assert secret not in result.stdout + result.stderr, 'configuration leaked'
reports.append({'shell':name, 'correctImage':correct, 'expectedExitObserved':True})
print(json.dumps({'cases':reports,'seconds':round(time.monotonic()-started,3)}))
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=scripts/init-security-env.sh
. "$SCRIPT_DIR/init-security-env.sh"
tmp_dir="$(mktemp -d)"; trap 'rm -rf "$tmp_dir"' EXIT
touch "$tmp_dir/.env"
write_env_value "$tmp_dir/.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY "original-master-with-dollar-\$1-and-backslash-\\"
write_env_value "$tmp_dir/.env" CONFIG_JWT_SECRET original-jwt-fallback
init_credential_encryption_env "$tmp_dir/.env" new > "$tmp_dir/log"
old_ring="$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
second="$(openssl rand -base64 32 | tr -d '\r\n')"
multi="${old_ring%\}},\"v2\":\"$second\"}"
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$multi'"
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID v2
write_env_value "$tmp_dir/.env" UNRELATED_CONFIG keep-me
bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log"
[ "$(read_env_value "$tmp_dir/keys.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" = "$multi" ]
if bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$old_ring'"
write_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID v1
bash "$SCRIPT_DIR/credential-keys.sh" restore "$tmp_dir/keys.env" "$tmp_dir/.env" >> "$tmp_dir/log"
[ "$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)" = "$multi" ]
[ "$(read_env_value "$tmp_dir/.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID)" = v2 ]
[ "$(read_env_value "$tmp_dir/.env" UNRELATED_CONFIG)" = keep-me ]
[ "$(read_env_value "$tmp_dir/.env" CONFIG_JWT_SECRET)" = original-jwt-fallback ]
[ "$(read_env_value "$tmp_dir/.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY)" = "$(read_env_value "$tmp_dir/keys.env" CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY)" ]
cp "$tmp_dir/.env" "$tmp_dir/before"
cp "$tmp_dir/keys.env" "$tmp_dir/bad.env"
write_env_value "$tmp_dir/bad.env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING '{}'
if bash "$SCRIPT_DIR/credential-keys.sh" restore "$tmp_dir/bad.env" "$tmp_dir/.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi
cmp -s "$tmp_dir/.env" "$tmp_dir/before"
touch "$tmp_dir/legacy.env"
if bash "$SCRIPT_DIR/credential-keys.sh" export "$tmp_dir/missing.env" "$tmp_dir/legacy.env" >> "$tmp_dir/log" 2>&1; then exit 1; fi
[ ! -e "$tmp_dir/missing.env" ]
if grep -Fq "$second" "$tmp_dir/log"; then exit 1; fi
if stat -f '%Lp' "$tmp_dir/keys.env" > /dev/null 2>&1; then permission="$(stat -f '%Lp' "$tmp_dir/keys.env")"; else permission="$(stat -c '%a' "$tmp_dir/keys.env")"; fi
[ "$permission" = 600 ]
echo 'Credential key backup/restore self-test passed (rotation, preservation, refusal, permissions, logs).'
+33 -5
View File
@@ -37,7 +37,7 @@ def fixture(base):
(root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n')
(root / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n')
(root / 'data').mkdir()
(root / '.env').write_text('SYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
(root / '.env').write_text('VERSION=3.23.0.20261003\nMODEL_CONVERSION_WORKER_VERSION=3.23.0.20261003\nSYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
(root / '.gitignore').write_text('.env\n*.log\n')
commands = base / 'commands.jsonl'
commands.touch()
@@ -57,6 +57,12 @@ if args[:1] == ['inspect']:
sys.exit(0)
sys.exit(1)
if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasyai-web\\nws-gateway\\neasyai-updater\\nmodel-conversion-worker\\nmongo\\nredis')
elif args[:4] == ['compose', 'config', '--format', 'json']:
names = {'easyai-server':('SERVER_VERSION','comfy-ai/comfy-server'), 'easyai-web':('WEB_VERSION','comfy-ai/one-ai'), 'ws-gateway':('WS_GATEWAY_VERSION','easyaigc/wsgateway'), 'easyai-updater':('UPDATER_VERSION','easyaigc/easyai-updater'), 'model-conversion-worker':('MODEL_CONVERSION_WORKER_VERSION','easyaigc/model-conversion-worker')}
values = dict(line.split('=',1) for line in open('.env').read().splitlines() if '=' in line and not line.startswith('#'))
key,image = names[args[-1]]
actual = 'registry.cn-shanghai.aliyuncs.com/'+image+':'+('latest' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values[key])
print(json.dumps({'services': {args[-1]: {'image':actual}, 'dependency': {'image':'redis:7-alpine'}}}))
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
sys.exit(0)
''')
@@ -105,6 +111,19 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert original == (root / '.env').read_bytes(), 'repeat changes deployment settings'
log.write_text('')
original = (root / '.env').read_bytes()
(root / '.env').write_text((root / '.env').read_text().replace('VERSION=3.23.0.20261003', 'VERSION=latest'))
rejected_env = (root / '.env').read_bytes()
result = run(command, root, env)
assert result.returncode != 0 and '固定版本' in result.stdout + result.stderr
assert rejected_env == (root / '.env').read_bytes()
assert not any(c[:2] in [['compose','pull'],['compose','up']] for c in calls(log))
(root / '.env').write_bytes(original)
log.write_text('')
result = run(command, root, {**env, 'UPDATE_TEST_IMAGE_OVERRIDE':'1'})
assert result.returncode != 0 and '有效镜像' in result.stdout + result.stderr
assert not any(c[:2] in [['compose','pull'],['compose','up']] for c in calls(log))
log.write_text('')
original = (root / '.env').read_bytes()
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': 'latest;unsafe'})
assert result.returncode != 0
assert original == (root / '.env').read_bytes()
@@ -121,7 +140,16 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
result = run(command, root, {**env, 'UPDATE_TEST_UNHEALTHY': '1', 'UPDATE_HEALTH_TIMEOUT_SECONDS': '1'})
assert result.returncode != 0
assert '应用更新成功' not in result.stdout
if name == 'bash':
if name in ('bash','powershell'):
log.write_text('')
before = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
result = run(command, root, {**env, 'UPDATE_SERVICES':'easyai-server', 'UPDATE_RELEASE_VERSION':'3.25.0.20261009.2'})
assert result.returncode == 0, result.stdout + result.stderr
after = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert after['SERVER_VERSION'] == '3.25.0.20261009.2'
assert after['VERSION'] == before['VERSION'] and after['WEB_VERSION'] == before['WEB_VERSION']
assert after['MODEL_CONVERSION_WORKER_VERSION'] == before['MODEL_CONVERSION_WORKER_VERSION']
assert ['compose','up','-d','--no-deps','easyai-server'] in calls(log)
log.write_text('')
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server easyai-web', 'UPDATE_RELEASE_VERSION': '3.25.0.20261009.3'})
assert result.returncode == 0, result.stdout + result.stderr
@@ -132,7 +160,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert ['compose', 'up', '-d', '--no-deps', 'easyai-server', 'easyai-web'] in calls(log)
assert not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']])
values = core_values
print('bash: core-only release preserves non-target Worker pin passed')
print(f'{name}: core-only release preserves non-target Worker pin passed')
companion_env = {**env, 'UPDATE_SERVICES': 'ws-gateway easyai-updater',
'UPDATE_GATEWAY_VERSION': '3.25.0.20261009',
'UPDATE_UPDATER_VERSION': '3.25.0.20261009'}
@@ -154,7 +182,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert new_values['VERSION'] == values['VERSION']
assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
assert new_values['CONFIG_JWT_SECRET'] == values['CONFIG_JWT_SECRET']
print('bash: independent companion pins preserve non-target versions and reject invalid scope passed')
print(f'{name}: independent companion pins preserve non-target versions and reject invalid scope passed')
print(f'{name}: selected services, stable secret, invalid service and unhealthy Worker passed')
case = base / 'self-update'
@@ -178,7 +206,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]:
assert run(args, seed).returncode == 0
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))
compose_path.write_text(compose_path.read_text().replace('comfy-ai/one-ai:${WEB_VERSION:-${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
+19
View File
@@ -122,4 +122,23 @@ assert_value "$new_env" "WS_AUTH_METHODS" "none,bearer,ws_ticket"
mode="$(stat -c '%a' "$new_env" 2>/dev/null || stat -f '%Lp' "$new_env")"
[ "$mode" = "600" ] || fail "generated environment file mode must be 600, got $mode"
assert_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING ''
assert_value "$new_env" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT false
new_ring="$(read_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
new_key="${new_ring#*:\"}"; new_key="${new_key%\"\}}"
[ "$(printf '%s' "$new_key" | openssl base64 -d -A | wc -c | tr -d ' ')" = 32 ] || fail 'credential key length must be 32 bytes'
if grep -Fq "$new_key" "$TMP_DIR/new.log"; then fail 'credential key must not appear in logs'; fi
DEPLOY_ENABLE_CREDENTIAL_ENCRYPTION=true init_security_env "$upgrade_env" upgrade > "$TMP_DIR/opt-in.log"
assert_value "$upgrade_env" CONFIG_CREDENTIAL_ALLOW_LEGACY_PLAINTEXT true
opted_ring="$(read_env_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING)"
init_security_env "$upgrade_env" upgrade > "$TMP_DIR/opt-repeat.log"
assert_value "$upgrade_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "$opted_ring"
write_env "$TMP_DIR/partial.env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID=v1 CONFIG_CREDENTIAL_ENCRYPTION_KEYRING=
if init_security_env "$TMP_DIR/partial.env" upgrade > "$TMP_DIR/partial.log" 2>&1; then fail 'partial keyring must fail'; fi
rotate_key="$(openssl rand -base64 32 | tr -d '\r\n')"
rotated="${new_ring%\}},\"next\":\"${rotate_key}\"}"
write_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "'$rotated'"
write_env_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_ACTIVE_KEY_ID next
init_security_env "$new_env" upgrade > "$TMP_DIR/rotation.log"
assert_value "$new_env" CONFIG_CREDENTIAL_ENCRYPTION_KEYRING "$rotated"
echo "Security environment shell tests passed"
+2 -2
View File
@@ -27,7 +27,7 @@ grep -qx 'WATCHTOWER_CLEANUP=' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_HTTP_API_PERIODIC_POLLS=false' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_UPDATE_TARGET=easyai-server easyai-wsgateway easyai-web' "$LEGACY_ENV_FILE"
grep -qx 'WATCHTOWER_IMAGE=registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_WORKER_VERSION=latest' "$LEGACY_ENV_FILE"
! grep -q '^MODEL_CONVERSION_WORKER_VERSION=' "$LEGACY_ENV_FILE"
grep -qx 'MODEL_CONVERSION_HUNYUAN_ENABLED=false' "$LEGACY_ENV_FILE"
cp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env"
bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null
@@ -57,7 +57,7 @@ read_value() {
[ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ]
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ]
[ "$(read_value MODEL_CONVERSION_WORKER_VERSION)" = "latest" ]
[ -z "$(read_value MODEL_CONVERSION_WORKER_VERSION)" ]
[ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ]
cp "$ENV_FILE" "$TMP_DIR/first-run.env"
@@ -1,3 +1,3 @@
# 独立模型转换;不改变现有在线更新模式与付费选择。
MODEL_CONVERSION_WORKER_VERSION=latest
MODEL_CONVERSION_HUNYUAN_ENABLED=false
@@ -0,0 +1,2 @@
# 只补充缺失或空值,自定义目录地址仍由部署管理员控制。
CONFIG_OFFICIAL_MODEL_CATALOG_URL=https://git.51easyai.com/wangbo/easyai/raw/branch/main/model-catalog/official/latest.json
+30 -18
View File
@@ -1,13 +1,13 @@
{
"schemaVersion": 1,
"channel": "stable",
"releaseId": "3.28.2.20261011.4-86e94f19e538",
"productVersion": "3.28.2.20261011.4",
"releaseId": "3.30.2.20261017-1e180b4dc7d1",
"productVersion": "3.30.2.20261017",
"platform": "linux/amd64",
"publishedAt": "2026-10-11T09:46:39.697960+00:00",
"publishedAt": "2026-10-17T01:56:17.039Z",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
@@ -15,23 +15,35 @@
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"digest": "sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11",
"buildId": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
"imageSizeBytes": 989020204
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874
"tag": "3.30.2.20261017",
"digest": "sha256:391e2d16bcf3812a3b76e4eacc15c7587588b2c65effc2e8c7d078a7f3c7335a",
"buildId": "17e301d5f4253a3e5f5affe0ca579ae9d15da24e",
"imageSizeBytes": 989169330,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc",
"buildId": "f1925755cb481b9c3a609996333d26bf47da5302",
"imageSizeBytes": 347958996
"tag": "3.30.2.20261017",
"digest": "sha256:6c7f6083731dca23f7e0ac4b1a96b1ea8b4b66b00f5e23f73afd7a6b22c2ef0a",
"buildId": "e0aa6375fe621ee06f964d6a988fbae350ecaa01",
"imageSizeBytes": 348157489,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64"
}
]
],
"recordSource": "本轮主分支源码实际构建的固定版本;网关和模型转换Worker复用原固定产物及原构建来源",
"releaseNotes": "修复画布节点和智能体计费预占、消费冲突证据及大语言模型消费平台筛选;兼容仅支持Base64的媒体输入,优化图像超时与视频等待;修复后台首开、模型弹窗、消费参数预览与超分尺寸。",
"additionalPublishedAliases": [
"registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.30.2.20261017-hmg-ai"
],
"promotedFromPublishedAt": "2026-10-17T03:20:48.454614+08:00"
}
@@ -0,0 +1,56 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.29.0.20261014.2-610c1e33ae02",
"productVersion": "3.29.0.20261014.2",
"platform": "linux/amd64",
"publishedAt": "2026-10-13T16:59:32.229224+00:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"digest": "sha256:a146cd64452f830ba589497be77edce10014a64de6d61abeb32533652dfc7cdc",
"buildId": "b7d82b76bd49c02aacad50bfe778dc256a41c4c0",
"imageSizeBytes": 990412653,
"tag": "3.29.0.20261014.2"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"tag": "3.25.0.20261009"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:0140956e880d0c9eec0da1ebd1e3bcba20312d96b0161b5978c8c13f16335e5b",
"buildId": "c685301b169bdcd662a97769a33bf69e3668f4c3",
"imageSizeBytes": 348004004,
"tag": "3.29.0.20261014.2"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261014.2-no-ssr",
"digest": "sha256:cbc47061109e21a4b9d668dc3cefe3fda8cb3058124ebab333dafd087a4aa49b",
"buildId": "c685301b169bdcd662a97769a33bf69e3668f4c3",
"imageSizeBytes": 312754106
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "retained-formal-image"
}
],
"recordSource": "既有3.29.0.20261014.2成功生产/文档记录;仅建立版本记录,未执行新的稳定提升"
}
@@ -0,0 +1,61 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.29.0.20261015.2-248ca4e3a2e5",
"productVersion": "3.29.0.20261015.2",
"platform": "linux/amd64",
"publishedAt": "2026-10-15T12:24:36.871239+00:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"digest": "sha256:c53dfbc97881c3073496ad3afc3b4e81d72122a5125258e3b975ea3af1171380",
"buildId": "ae0008ba394043d2d1a233a2ff708c0a96ace6ab",
"imageSizeBytes": 990484390,
"tag": "3.29.0.20261015.2",
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:de63ed93d3a7b81e52a0cb12db519eadbc33513f74af6cf01d30fcb22547e45a",
"buildId": "343e8cc816b0f9cd7449faa44680035dd47900ec",
"imageSizeBytes": 348011223,
"tag": "3.29.0.20261015.2",
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"tag": "3.25.0.20261009",
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"digest": "sha256:03a8bb6c2f3a5200354a263b8ea12b0422c8f04b4e778d79da9be73746adcf1b",
"buildId": "343e8cc816b0f9cd7449faa44680035dd47900ec",
"imageSizeBytes": 314313406,
"tag": "3.29.0.20261015.2-no-ssr",
"platform": "linux/amd64"
}
],
"recordSource": "由已合并源码构建并发布的固定版本;WebSocket 网关复用未变更的正式产物",
"releaseNotes": "取回已有结果后自动同步画布;浏览器通过预签名上传补存并同步历史与画布,保留原结果、失败记录及结算状态。",
"sourcePullRequests": [
"https://git.51easyai.com/BCAI/comfy-server/pulls/109",
"https://git.51easyai.com/BCAI/comfy-AI2B/pulls/153"
],
"additionalPublishedAliases": [
"registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.29.0.20261015.2-hmg-ai"
]
}
@@ -0,0 +1,76 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.29.0.20261015-48691f9f4be4",
"productVersion": "3.29.0.20261015",
"platform": "linux/amd64",
"publishedAt": "2026-10-15T03:26:33.815531+08:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"tag": "3.29.0.20261015",
"digest": "sha256:df3ced2b5a1d6724a4612cb9373b60e33fc413dc9efd1516014ce26360459b6a",
"buildId": "fa5ba3fd063677ff11b7e9eddce12da476ea10cc",
"imageSizeBytes": 989102531,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261015",
"digest": "sha256:cba3a7aeabfaecfae59982b134cec3b44438ac8b7c582ece84c0fa5b5447f3da",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 348069167,
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261015-no-ssr",
"digest": "sha256:2b2c1616dd58489f12d1db82daf870e831d52de51804b3c717ce5a6528a52351",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 312779834,
"platform": "linux/amd64"
},
{
"key": "hmg-ai",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.29.0.20261015-hmg-ai",
"digest": "sha256:2b2c1616dd58489f12d1db82daf870e831d52de51804b3c717ce5a6528a52351",
"buildId": "917dd4080cea4c99be3d98780093a1acd51c6407",
"imageSizeBytes": 312779834,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64",
"reusedFromTag": "3.25.0.20261009",
"reuseSource": "existing successful production artifact; unchanged runtime source and dependencies"
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "83cefbfdf2bc909d11ace9f020b108145844051a",
"imageSizeBytes": 118327684,
"platform": "linux/amd64",
"amd64Digest": "sha256:085fa49d99a06562ce9634bfdd8567efdfe4e38754e9688de81288d2e2578526",
"reusedFromTag": "3.25.0.20261009.2",
"reuseSource": "existing successful production artifact; unchanged runtime source and dependencies"
}
],
"recordSource": "2026-10-15 daily fixed-version publication; stable promotion was not executed"
}
@@ -0,0 +1,66 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.30.0.20261016-3e558e56d78e",
"productVersion": "3.30.0.20261016",
"platform": "linux/amd64",
"publishedAt": "2026-10-16T03:29:39.247892+08:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"tag": "3.30.0.20261016",
"digest": "sha256:57cac83fb29180fd10591a5960b1e258359f26af5d2d31370674e8e14aaf107e",
"buildId": "e99b636982fc654fdb7f3dcbe08db57bd3b047bc",
"imageSizeBytes": 989161034,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.0.20261016",
"digest": "sha256:c2adf0577536f7eed402ce0043f2d708c2294700e8be9a51fbd29112ec1f644d",
"buildId": "b6850f138cd831972fcb35ac8fc456f30fe98609",
"imageSizeBytes": 348127493,
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.0.20261016-no-ssr",
"digest": "sha256:9240e9689aa9862ebd21035499235e7a3a0817de93274a72dca0b821ea1fbb8d",
"buildId": "b6850f138cd831972fcb35ac8fc456f30fe98609",
"imageSizeBytes": 312836808,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64"
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "83cefbfdf2bc909d11ace9f020b108145844051a",
"imageSizeBytes": 118327684,
"platform": "linux/amd64"
}
],
"recordSource": "本轮主分支源码实际构建的固定版本;网关和模型转换Worker复用原固定产物及原构建来源",
"releaseNotes": "消费记录按实际执行主平台筛选并统一明细与导出;生成结果自动恢复并支持浏览器转存兜底;本地素材拖入后先上传持久化;优化3D轮询与聊天审计。画布归档代码交付,当前生产归档开关保持关闭。",
"additionalPublishedAliases": [
"registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.30.0.20261016-hmg-ai"
]
}
@@ -0,0 +1,63 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.30.1.20261016-280cb14aee11",
"productVersion": "3.30.1.20261016",
"platform": "linux/amd64",
"publishedAt": "2026-10-16T11:05:49.779738+08:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"tag": "3.30.1.20261016",
"digest": "sha256:280cb14aee113fe3e73ffb74d6a18ee48520615af46b69e46c52fe97b3aa6143",
"buildId": "30cb3019eecd5479b0ed0ff1e3949005098c2f20",
"imageSizeBytes": 989163608,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.0.20261016",
"digest": "sha256:c2adf0577536f7eed402ce0043f2d708c2294700e8be9a51fbd29112ec1f644d",
"buildId": "b6850f138cd831972fcb35ac8fc456f30fe98609",
"imageSizeBytes": 348127493,
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.1.20261016-no-ssr",
"digest": "sha256:9240e9689aa9862ebd21035499235e7a3a0817de93274a72dca0b821ea1fbb8d",
"buildId": "b6850f138cd831972fcb35ac8fc456f30fe98609",
"imageSizeBytes": 312836808,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64"
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "83cefbfdf2bc909d11ace9f020b108145844051a",
"imageSizeBytes": 118327684,
"platform": "linux/amd64"
}
],
"recordSource": "后端从主线构建 30cb3019;前端、WebSocket 网关及模型转换 Worker 复用既有正式固定产物",
"releaseNotes": "生成成功后缺失用量或计费、转存异常保留原结果和原任务,避免换平台重复生成;优化消费筛选查询"
}
@@ -0,0 +1,66 @@
{
"schemaVersion": 1,
"channel": "versioned",
"releaseId": "3.30.2.20261017-1e180b4dc7d1",
"productVersion": "3.30.2.20261017",
"platform": "linux/amd64",
"publishedAt": "2026-10-17T03:20:48.454614+08:00",
"managedUpdateAllowed": true,
"scheduledUpdateAllowed": true,
"rollbackSafe": false,
"minimumUpdaterVersion": "1.1.0",
"minimumDeploymentSchemaVersion": 2,
"requiredFreeBytes": 8589934592,
"components": [
{
"key": "server-main",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
"tag": "3.30.2.20261017",
"digest": "sha256:391e2d16bcf3812a3b76e4eacc15c7587588b2c65effc2e8c7d078a7f3c7335a",
"buildId": "17e301d5f4253a3e5f5affe0ca579ae9d15da24e",
"imageSizeBytes": 989169330,
"platform": "linux/amd64"
},
{
"key": "web",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.2.20261017",
"digest": "sha256:6c7f6083731dca23f7e0ac4b1a96b1ea8b4b66b00f5e23f73afd7a6b22c2ef0a",
"buildId": "e0aa6375fe621ee06f964d6a988fbae350ecaa01",
"imageSizeBytes": 348157489,
"platform": "linux/amd64"
},
{
"key": "web-no-ssr",
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
"tag": "3.30.2.20261017-no-ssr",
"digest": "sha256:6b72ac71499e9bbcdfb0a94a82258c68121aab46e44dc4825a52c54f8bb41935",
"buildId": "e0aa6375fe621ee06f964d6a988fbae350ecaa01",
"imageSizeBytes": 312866555,
"platform": "linux/amd64"
},
{
"key": "ws-gateway",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
"tag": "3.25.0.20261009",
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
"buildId": "retained-formal-image",
"imageSizeBytes": 751971874,
"platform": "linux/amd64"
},
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e",
"buildId": "83cefbfdf2bc909d11ace9f020b108145844051a",
"imageSizeBytes": 118327684,
"platform": "linux/amd64"
}
],
"recordSource": "本轮主分支源码实际构建的固定版本;网关和模型转换Worker复用原固定产物及原构建来源",
"releaseNotes": "修复画布节点和智能体计费预占、消费冲突证据及大语言模型消费平台筛选;兼容仅支持Base64的媒体输入,优化图像超时与视频等待;修复后台首开、模型弹窗、消费参数预览与超分尺寸。",
"additionalPublishedAliases": [
"registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:3.30.2.20261017-hmg-ai"
]
}
+9 -9
View File
@@ -59,7 +59,10 @@ if ($args -contains "-h" -or $args -contains "--help") {
Write-Host " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
Write-Host " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。"
Write-Host " UPDATE_RELEASE_VERSION=固定版本"
Write-Host " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。"
Write-Host " 将已选应用的固定版本持久化到 .env;latest 仅为人工稳定版。"
Write-Host " UPDATE_SERVER_VERSION / UPDATE_WEB_VERSION / UPDATE_WORKER_VERSION=固定版本"
Write-Host " UPDATE_GATEWAY_VERSION / UPDATE_UPDATER_VERSION / UPDATE_ASG_VERSION / UPDATE_AMS_VERSION=固定版本"
Write-Host " 独立参数优先,只改变选中应用的版本。"
Write-Host " UPDATE_NO_WAIT=1"
Write-Host " 结束或失败时不等待按 Enter"
Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
@@ -201,6 +204,8 @@ if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps
# 每次更新都执行版本化、幂等的环境配置迁移;已有非空自定义值保持不变。
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
$environmentPath = Join-Path $scriptDir ".env"
. (Join-Path $scriptDir "scripts/Initialize-ApplicationVersions.ps1")
try { $applicationVersionPlan = @(Get-ApplicationVersionPlan -Path $environmentPath) } catch { Write-Err $_.Exception.Message }
$environmentContent = Get-Content $environmentPath -Raw -Encoding UTF8
if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) {
& docker inspect easyai-server *> $null
@@ -222,14 +227,7 @@ Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env")
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
if ($env:UPDATE_RELEASE_VERSION) {
$environmentPath = Join-Path $scriptDir ".env"
$environmentContent = Get-Content -Raw -Encoding UTF8 $environmentPath
$environmentContent = Set-EnvValue $environmentContent "VERSION" $env:UPDATE_RELEASE_VERSION
$environmentContent = Set-EnvValue $environmentContent "MODEL_CONVERSION_WORKER_VERSION" $env:UPDATE_RELEASE_VERSION
[System.IO.File]::WriteAllText($environmentPath, $environmentContent, (New-Object System.Text.UTF8Encoding $false))
Write-Step "📌 已固定本次发布版本: $($env:UPDATE_RELEASE_VERSION)"
}
Set-ApplicationVersions -Path $environmentPath -Plan $applicationVersionPlan
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
@@ -372,6 +370,8 @@ if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SERVICES)) {
if ($availableServices -notcontains $service) { Write-Err "更新服务不存在: $service;尚未拉取或重建任何服务" }
}
}
$composeCommand = if ($hasComposeV2) { @("docker","compose") } else { @("docker-compose") }
try { Test-ApplicationVersionImages -Plan $applicationVersionPlan -ComposeCommand $composeCommand } catch { Write-Err $_.Exception.Message }
$upArguments = @("up", "-d")
if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices }
if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") {
+9 -16
View File
@@ -20,9 +20,12 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。"
echo " UPDATE_RELEASE_VERSION=固定版本"
echo " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。"
echo " 通过脚本将已选应用的固定版本持久化到 .env;latest 仅为人工稳定版。"
echo " UPDATE_GATEWAY_VERSION=固定版本 / UPDATE_UPDATER_VERSION=固定版本"
echo " 仅对已选中的网关或更新服务固定已核验镜像。"
echo " UPDATE_SERVER_VERSION / UPDATE_WEB_VERSION / UPDATE_WORKER_VERSION=固定版本"
echo " 单独指定组件版本;不改变未选中服务的版本。"
echo " UPDATE_ASG_VERSION / UPDATE_AMS_VERSION=固定版本"
echo " UPDATE_ADOPT_LEGACY_CONFIG=1"
echo " 备份旧部署配置并将本地差异迁移为独立覆盖文件。"
echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'"
@@ -185,6 +188,8 @@ fi
# 已安装但没有 updater 的旧部署保留其更新方式;新安装仍默认 managed。
# shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh
. ./scripts/init-application-version-env.sh
plan_application_versions .env
if [ -z "$(read_env_value .env SYSTEM_UPDATE_MODE)" ] && docker inspect easyai-server >/dev/null 2>&1 && ! docker inspect easyai-updater >/dev/null 2>&1; then
write_env_value .env SYSTEM_UPDATE_MODE legacy_watchtower
fi
@@ -200,21 +205,7 @@ init_server_http_bind_ip .env
init_security_env .env upgrade
bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR"
if [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then
write_env_value .env VERSION "$UPDATE_RELEASE_VERSION"
# 定向发布主服务时保留未参与的 Worker 固定版本。
if [ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" model-conversion-worker "* ]]; then
write_env_value .env MODEL_CONVERSION_WORKER_VERSION "$UPDATE_RELEASE_VERSION"
fi
echo "📌 已固定本次发布版本: $UPDATE_RELEASE_VERSION"
fi
if [ -n "${UPDATE_GATEWAY_VERSION:-}" ]; then
write_env_value .env WS_GATEWAY_VERSION "$UPDATE_GATEWAY_VERSION"
fi
if [ -n "${UPDATE_UPDATER_VERSION:-}" ]; then
write_env_value .env UPDATER_VERSION "$UPDATE_UPDATER_VERSION"
fi
apply_application_versions .env
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
# shellcheck source=scripts/init-public-api-base-url.sh
@@ -342,6 +333,8 @@ if [ -n "${UPDATE_SERVICES:-}" ]; then
done
fi
verify_application_version_images .env "$DOCKER_COMPOSE_CMD"
# 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。
if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then
sudo ./scripts/check-redis-persistence.sh