Compare commits
36
Commits
f59dd33097
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
895d92317e | ||
|
|
be086ab2cc | ||
|
|
e15fb6b0f1 | ||
|
|
2b15588caf | ||
|
|
4df7625052 | ||
|
|
1f12419742 | ||
|
|
24d7efbb1b | ||
|
|
9a55fcfe5b | ||
|
|
8d60d463ec | ||
|
|
3641e91346 | ||
|
|
9547a027a2 | ||
|
|
dad460581e | ||
|
|
e78c183888 | ||
|
|
dbb6bbce02 | ||
|
|
2db18f5804 | ||
|
|
ef471e2106 | ||
|
|
bf0ac6db54 | ||
|
|
9a801ea3d5 | ||
|
|
6691ac34e0 | ||
|
|
8c1eadc5ea | ||
|
|
5fa19eff08 | ||
|
|
30c5d31787 | ||
|
|
e337f6b7fb | ||
|
|
15a36fd085 | ||
|
|
b77f27f6d5 | ||
|
|
25e38b4441 | ||
|
|
111ccab266 | ||
|
|
23c9a989d4 | ||
|
|
e082e24be7 | ||
|
|
ede392c310 | ||
|
|
7114eeee01 | ||
|
|
85399bb185 | ||
|
|
79e6bdf65e | ||
|
|
ffaa01d434 | ||
|
|
4cff2dcc58 | ||
|
|
5cff1041a9 |
+18
-2
@@ -128,6 +128,14 @@ CONFIG_JWT_SECRET=
|
|||||||
# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。
|
# 启动/升级脚本会为新部署生成;数据库中已有的 JWT 轮转配置优先,不会被覆盖。
|
||||||
CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY=
|
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 短时票据专用签名密钥。
|
# Canvas CLI / Agent WebSocket 短时票据专用签名密钥。
|
||||||
# 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。
|
# 启动和升级脚本会在缺失、过短时自动生成并持久化,无需手动填写。
|
||||||
WS_AUTH_WS_TICKET_SECRET=
|
WS_AUTH_WS_TICKET_SECRET=
|
||||||
@@ -251,7 +259,15 @@ STATIC_CDN_OSS_USE_SSL=true
|
|||||||
STATIC_CDN_FORCE_UPLOAD=false
|
STATIC_CDN_FORCE_UPLOAD=false
|
||||||
|
|
||||||
# ========== 15. 版本 ==========
|
# ========== 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 在线更新 ==========
|
# ========== 15.1 在线更新 ==========
|
||||||
# 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。
|
# 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。
|
||||||
@@ -286,7 +302,7 @@ EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
|
|||||||
# ========== 15.2 3D 模型轻量转换 ==========
|
# ========== 15.2 3D 模型轻量转换 ==========
|
||||||
# update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。
|
# update 脚本自动补齐缺失配置并拉取独立容器,无需手动修改线上配置。
|
||||||
# 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。
|
# 可填写已发布版本号固定镜像;latest 跟随正式转换镜像。
|
||||||
MODEL_CONVERSION_WORKER_VERSION=latest
|
MODEL_CONVERSION_WORKER_VERSION=
|
||||||
# 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。
|
# 安装/升级时自动生成,API 和 Worker 共用。已有强令牌不会重新生成。
|
||||||
MODEL_CONVERSION_WORKER_TOKEN=
|
MODEL_CONVERSION_WORKER_TOKEN=
|
||||||
# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。
|
# 付费通道默认关闭;自建失败不会自动扣费转入其他通道。
|
||||||
|
|||||||
+12
@@ -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/
|
||||||
@@ -42,6 +42,10 @@ Linux IP 模式会自动安装并配置 Nginx,浏览器统一访问 `http://IP
|
|||||||
|
|
||||||
生成结果仅保存在本机 `.env`。除首次安装完成页会明确显示登录用的初始管理员密码外,脚本日志对其他安全密钥只显示变量名,不输出密钥内容。不要把 `.env` 提交到 Git;集群部署时,各 `easyai-server` / `ws-gateway` 实例必须使用同一份持久化配置。
|
生成结果仅保存在本机 `.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`,并在部署完成页直接显示:
|
首次安装时,部署脚本会生成 20 位随机密码,写入 `.env` 的 `CONFIG_INITIAL_ADMIN_PASSWORD`,并在部署完成页直接显示:
|
||||||
@@ -171,6 +175,10 @@ mv easyai easyai2
|
|||||||
|
|
||||||
5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。
|
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 应用,包含以下功能:
|
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
|
||||||
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
|
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
|
||||||
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
|
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
|
||||||
@@ -186,7 +194,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
|
|||||||
|
|
||||||
更新执行器 `easyai-updater` 由 Watchtower 默认每 24 小时检查更新,同时由宿主机定时任务每六小时直接检查更新,提供两条独立维护路径。它的任务状态保存在宿主机 `data/update-state` 中,自身更新或意外重启后会继续恢复未完成任务;Watchtower 不再接管三个核心服务,因此页面任务结果不会与后台静默替换相互冲突。
|
更新执行器 `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` 路径覆盖不再影响默认远程模式。
|
发布源不可用时会提示暂时无法检查更新,并保留当前服务;不会用历史清单宣称已是最新。`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 分钟后刷新检查版本;只有确认目标版本和服务恢复才显示完成。
|
两个页面入口共用计时和步骤窗口。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`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。
|
以后新增必需环境变量时,在 `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` 后会**命令行内选择**更新方式:
|
- 执行 `./update.sh` 后会**命令行内选择**更新方式:
|
||||||
- `[1]` 更新并拉取仓库(git pull)+ 更新镜像并重启(**默认**,回车即选)
|
- `[1]` 更新并拉取仓库(git pull)+ 更新镜像并重启(**默认**,回车即选)
|
||||||
- `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景)
|
- `[2]` 仅更新镜像并重启(跳过 git pull,适用于有本地修改不想被覆盖的场景)
|
||||||
|
- API Key 加密首次接入需要新版脚本及历史迁移,请按 [加密升级说明](docs/API_KEY_ENCRYPTION_DEPLOYMENT.md) 执行;只有尚未接入加密的旧部署可以继续仅更新兼容镜像。
|
||||||
- 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。
|
- 如果本次**不涉及配置文件更新**(如 `.env*`、`docker-compose.yml`、`easyai-proxy.conf*` 无变更),可直接选择 `[2]`,仅更新后台服务镜像并重启即可。
|
||||||
|
|
||||||
- 自动化更新可通过变量跳过选择:
|
- 自动化更新可通过变量跳过选择:
|
||||||
@@ -252,12 +261,13 @@ UPDATE_SKIP_REPO_UPDATE=true ./update.sh
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \
|
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;原模型始终保留,轻量转换失败可使用原模型。
|
脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `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` 标签。版本号不合法时在修改配置前终止。
|
测试候选版本时使用 `UPDATE_RELEASE_VERSION=3.23.0.20261003`,由更新脚本固定核心服务和转换 Worker 的版本,无需改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。
|
||||||
|
|
||||||
@@ -736,3 +746,7 @@ apt remove docker-compose
|
|||||||
```bash
|
```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 更新完成"
|
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 目录发布流程。
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 自有测试环境的后端候选验收。追加到既有 COMPOSE_FILE,仅选择主后端镜像。
|
||||||
|
# EASYAI_TEST_SERVER_IMAGE 必须为已发布并核验的完整版本镜像。
|
||||||
|
services:
|
||||||
|
easyai-server:
|
||||||
|
image: ${EASYAI_TEST_SERVER_IMAGE:?EASYAI_TEST_SERVER_IMAGE is required}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# 自有测试环境与已发布前端产物配对验收,仅选择前端镜像。
|
||||||
|
# 不参与生产或客户的默认更新配置。
|
||||||
|
services:
|
||||||
|
easyai-web:
|
||||||
|
image: ${EASYAI_TEST_WEB_IMAGE:?EASYAI_TEST_WEB_IMAGE is required}
|
||||||
+9
-7
@@ -5,7 +5,7 @@ services:
|
|||||||
easyai-web:
|
easyai-web:
|
||||||
container_name: 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
|
#端口冲突时,只需要修改前面的端口比如修改为,3011:3010
|
||||||
labels:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
easyai-server:
|
easyai-server:
|
||||||
container_name: 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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
||||||
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
||||||
@@ -88,6 +88,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
|
# Node.js V8 old-space 堆上限(MiB),可在 .env 中覆盖
|
||||||
- NODE_OPTIONS=--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE:-8192}
|
- 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配置,用户管理,不会配置的话保持默认即可
|
# MongoDB配置,用户管理,不会配置的话保持默认即可
|
||||||
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
|
- CONFIG_DB_MONGO_URI=mongodb://172.21.0.3:27017
|
||||||
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
|
- CONFIG_DB_MONGO_USERNAME=${MONGO_INITDB_ROOT_USERNAME}
|
||||||
@@ -170,7 +172,7 @@ services:
|
|||||||
max-file: "10"
|
max-file: "10"
|
||||||
model-conversion-worker:
|
model-conversion-worker:
|
||||||
container_name: easyai-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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
||||||
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
||||||
@@ -224,7 +226,7 @@ services:
|
|||||||
ws-gateway:
|
ws-gateway:
|
||||||
container_name: easyai-wsgateway # websocket网关
|
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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
|
||||||
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
||||||
@@ -391,7 +393,7 @@ services:
|
|||||||
max-size: "100m"
|
max-size: "100m"
|
||||||
max-file: "10"
|
max-file: "10"
|
||||||
easyai-updater:
|
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
|
container_name: easyai-updater
|
||||||
profiles:
|
profiles:
|
||||||
- managed-update
|
- managed-update
|
||||||
@@ -567,7 +569,7 @@ services:
|
|||||||
max-file: "10"
|
max-file: "10"
|
||||||
easyai-asg:
|
easyai-asg:
|
||||||
container_name: 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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
||||||
@@ -599,7 +601,7 @@ services:
|
|||||||
memory: 128MB
|
memory: 128MB
|
||||||
agent-memory:
|
agent-memory:
|
||||||
container_name: 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:
|
labels:
|
||||||
- "com.centurylinklabs.watchtower.enable=true"
|
- "com.centurylinklabs.watchtower.enable=true"
|
||||||
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
|
||||||
|
|||||||
@@ -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 和日志泄密。生产环境未执行迁移或部署。
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# EasyAI Windows 一键部署方案
|
# EasyAI Windows 一键部署方案
|
||||||
|
|
||||||
|
API Key 加密和未升级脚本用户的兼容路径统一见 [API Key 加密部署与旧用户兼容](API_KEY_ENCRYPTION_DEPLOYMENT.md)。新安装自动生成独立密钥;存量部署默认保持兼容,首次接入后迁移验证并重启,原用户 Key 和平台 Key 保持不变。密钥备份/还原入口为 `scripts/Manage-CredentialKeys.ps1`,数据库归档与原密钥分开保管,按上述说明先还原密钥、重建服务再还原数据。
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
`start.sh` 是 Linux 下的一键部署脚本,本方案为 Windows 平台提供等效的 `start.ps1` PowerShell 脚本。针对 Windows 典型场景做如下**精简**:
|
`start.sh` 是 Linux 下的一键部署脚本,本方案为 Windows 平台提供等效的 `start.ps1` PowerShell 脚本。针对 Windows 典型场景做如下**精简**:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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. 普通应用发布不修改模型定义。迁移不删除客户绑定、价格、权限,也不自动发起付费模型任务。
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# 官方模型目录维护规则
|
||||||
|
|
||||||
|
- 唯一维护源为本目录 `catalog.source.json`。平台后端和独立 AI Gateway 都是只读消费者,不在应用仓库另存维护源。
|
||||||
|
- 内容和版本指针通过 Git 提交发布,公开地址见 README。禁止使用旧 OSS/CDN 上传流程;应用镜像发布不自动授权修改模型定义或生命周期。
|
||||||
|
- 变更前同步最新主线,检查新增、修改和下线差异;执行格式、Schema、测试和版本一致性检查,将维护源、版本快照和 latest.json 一并提交。
|
||||||
|
- 恢复历史定义时生成新的回退版本;不覆盖历史快照,不强推改写 Git 历史。
|
||||||
|
- 正常下线保留原目录身份并设置 offline;缺失模型默认拒绝准备版本。
|
||||||
|
- 不将客户绑定、价格、权限、账号或密钥放入公开目录。
|
||||||
@@ -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 提交记录用于确定修改人、时间、评审和内容差异;版本快照记录内容摘要及上一版本。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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",
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
@@ -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) 的有效镜像未使用环境中的固定版本;请通过部署仓库的受控迁移恢复镜像定义,尚未拉取或重建服务"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,48 @@ function Initialize-SecurityEnv {
|
|||||||
|
|
||||||
$content = Get-Content $Path -Raw -Encoding UTF8
|
$content = Get-Content $Path -Raw -Encoding UTF8
|
||||||
if ($null -eq $content) { $content = "" }
|
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 = @(
|
$definitions = @(
|
||||||
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 },
|
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 },
|
||||||
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
|
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
|
||||||
@@ -102,4 +144,12 @@ function Initialize-SecurityEnv {
|
|||||||
$content,
|
$content,
|
||||||
[System.Text.UTF8Encoding]::new($false)
|
[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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } }
|
||||||
@@ -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' }
|
||||||
@@ -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 }
|
||||||
@@ -80,6 +80,26 @@ try {
|
|||||||
Assert-MinLength $freshContent "CONFIG_INITIAL_ADMIN_PASSWORD" 12
|
Assert-MinLength $freshContent "CONFIG_INITIAL_ADMIN_PASSWORD" 12
|
||||||
Assert-Value $freshContent "WS_AUTH_METHODS" "none,bearer,ws_ticket"
|
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
|
Write-Host "Security environment PowerShell tests passed" -ForegroundColor Green
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item $tempDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|||||||
Executable
+50
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -115,6 +115,61 @@ ensure_initial_admin_password() {
|
|||||||
echo " ✓ 已初始化管理员密码: CONFIG_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() {
|
init_security_env() {
|
||||||
local file="${1:-.env}"
|
local file="${1:-.env}"
|
||||||
local mode="${2:-upgrade}"
|
local mode="${2:-upgrade}"
|
||||||
@@ -127,6 +182,7 @@ init_security_env() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
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" "CONFIG_JWT_SECRET" "this is a very secret secret"
|
||||||
ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET"
|
ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET"
|
||||||
ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN"
|
ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN"
|
||||||
|
|||||||
@@ -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[@]}"
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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)}))
|
||||||
@@ -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).'
|
||||||
@@ -37,7 +37,7 @@ def fixture(base):
|
|||||||
(root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n')
|
(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 / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n')
|
||||||
(root / 'data').mkdir()
|
(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')
|
(root / '.gitignore').write_text('.env\n*.log\n')
|
||||||
commands = base / 'commands.jsonl'
|
commands = base / 'commands.jsonl'
|
||||||
commands.touch()
|
commands.touch()
|
||||||
@@ -57,6 +57,12 @@ if args[:1] == ['inspect']:
|
|||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasyai-web\\nws-gateway\\neasyai-updater\\nmodel-conversion-worker\\nmongo\\nredis')
|
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])
|
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
|
||||||
sys.exit(0)
|
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'
|
assert original == (root / '.env').read_bytes(), 'repeat changes deployment settings'
|
||||||
log.write_text('')
|
log.write_text('')
|
||||||
original = (root / '.env').read_bytes()
|
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'})
|
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': 'latest;unsafe'})
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert original == (root / '.env').read_bytes()
|
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'})
|
result = run(command, root, {**env, 'UPDATE_TEST_UNHEALTHY': '1', 'UPDATE_HEALTH_TIMEOUT_SECONDS': '1'})
|
||||||
assert result.returncode != 0
|
assert result.returncode != 0
|
||||||
assert '应用更新成功' not in result.stdout
|
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('')
|
log.write_text('')
|
||||||
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server easyai-web', 'UPDATE_RELEASE_VERSION': '3.25.0.20261009.3'})
|
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
|
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 ['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']])
|
assert not any('model-conversion-worker' in call for call in calls(log) if call[:2] in [['compose', 'pull'], ['compose', 'up']])
|
||||||
values = core_values
|
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',
|
companion_env = {**env, 'UPDATE_SERVICES': 'ws-gateway easyai-updater',
|
||||||
'UPDATE_GATEWAY_VERSION': '3.25.0.20261009',
|
'UPDATE_GATEWAY_VERSION': '3.25.0.20261009',
|
||||||
'UPDATE_UPDATER_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['VERSION'] == values['VERSION']
|
||||||
assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
|
assert new_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
|
||||||
assert new_values['CONFIG_JWT_SECRET'] == values['CONFIG_JWT_SECRET']
|
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')
|
print(f'{name}: selected services, stable secret, invalid service and unhealthy Worker passed')
|
||||||
|
|
||||||
case = base / 'self-update'
|
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']]:
|
for args in [['git', 'add', 'update.sh'], ['git', 'commit', '-qm', 'test: 验证更新脚本自更新'], ['git', 'push', '-q', 'origin', 'HEAD']]:
|
||||||
assert run(args, seed).returncode == 0
|
assert run(args, seed).returncode == 0
|
||||||
compose_path = root / 'docker-compose.yml'
|
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'})
|
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 result.returncode == 0, result.stdout + result.stderr
|
||||||
assert '切换到新版更新脚本' in result.stdout
|
assert '切换到新版更新脚本' in result.stdout
|
||||||
|
|||||||
@@ -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="$(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"
|
[ "$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"
|
echo "Security environment shell tests passed"
|
||||||
|
|||||||
@@ -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_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_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 '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"
|
grep -qx 'MODEL_CONVERSION_HUNYUAN_ENABLED=false' "$LEGACY_ENV_FILE"
|
||||||
cp "$LEGACY_ENV_FILE" "$TMP_DIR/legacy-first-run.env"
|
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
|
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 UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ]
|
||||||
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
|
[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ]
|
||||||
[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ]
|
[ "$(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" ]
|
[ "$(read_value MODEL_CONVERSION_HUNYUAN_ENABLED)" = "false" ]
|
||||||
|
|
||||||
cp "$ENV_FILE" "$TMP_DIR/first-run.env"
|
cp "$ENV_FILE" "$TMP_DIR/first-run.env"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
# 独立模型转换;不改变现有在线更新模式与付费选择。
|
# 独立模型转换;不改变现有在线更新模式与付费选择。
|
||||||
MODEL_CONVERSION_WORKER_VERSION=latest
|
|
||||||
MODEL_CONVERSION_HUNYUAN_ENABLED=false
|
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
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"releaseId": "3.28.2.20261011.4-86e94f19e538",
|
"releaseId": "3.30.2.20261017-1e180b4dc7d1",
|
||||||
"productVersion": "3.28.2.20261011.4",
|
"productVersion": "3.30.2.20261017",
|
||||||
"platform": "linux/amd64",
|
"platform": "linux/amd64",
|
||||||
"publishedAt": "2026-10-11T09:46:39.697960+00:00",
|
"publishedAt": "2026-10-17T01:56:17.039Z",
|
||||||
"managedUpdateAllowed": true,
|
"managedUpdateAllowed": true,
|
||||||
"scheduledUpdateAllowed": true,
|
"scheduledUpdateAllowed": true,
|
||||||
"rollbackSafe": true,
|
"rollbackSafe": false,
|
||||||
"minimumUpdaterVersion": "1.1.0",
|
"minimumUpdaterVersion": "1.1.0",
|
||||||
"minimumDeploymentSchemaVersion": 2,
|
"minimumDeploymentSchemaVersion": 2,
|
||||||
"requiredFreeBytes": 8589934592,
|
"requiredFreeBytes": 8589934592,
|
||||||
@@ -15,23 +15,35 @@
|
|||||||
{
|
{
|
||||||
"key": "server-main",
|
"key": "server-main",
|
||||||
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
|
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server",
|
||||||
"digest": "sha256:2077104a3d9f1cba68eb31911bb00a26d2c154eda2c897fb031c162559379e11",
|
"tag": "3.30.2.20261017",
|
||||||
"buildId": "607c5ee243936bba394579c7bc94ca0b2f62cda6",
|
"digest": "sha256:391e2d16bcf3812a3b76e4eacc15c7587588b2c65effc2e8c7d078a7f3c7335a",
|
||||||
"imageSizeBytes": 989020204
|
"buildId": "17e301d5f4253a3e5f5affe0ca579ae9d15da24e",
|
||||||
},
|
"imageSizeBytes": 989169330,
|
||||||
{
|
"platform": "linux/amd64"
|
||||||
"key": "ws-gateway",
|
|
||||||
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway",
|
|
||||||
"digest": "sha256:1dd4247cf6a8a21d336876b5d97ecdc3e4ce43199b2f31aff18e0fab47bb4dca",
|
|
||||||
"buildId": "retained-formal-image",
|
|
||||||
"imageSizeBytes": 751971874
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "web",
|
"key": "web",
|
||||||
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
|
"image": "registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai",
|
||||||
"digest": "sha256:279da715ec67c2017c06ca31c8f7e172b703b99c5378404d24198af919c460dc",
|
"tag": "3.30.2.20261017",
|
||||||
"buildId": "f1925755cb481b9c3a609996333d26bf47da5302",
|
"digest": "sha256:6c7f6083731dca23f7e0ac4b1a96b1ea8b4b66b00f5e23f73afd7a6b22c2ef0a",
|
||||||
"imageSizeBytes": 347958996
|
"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
@@ -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 " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
|
||||||
Write-Host " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。"
|
Write-Host " 仅拉取并重建指定服务,不重建其依赖;留空保持整组更新。"
|
||||||
Write-Host " UPDATE_RELEASE_VERSION=固定版本"
|
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 " UPDATE_NO_WAIT=1"
|
||||||
Write-Host " 结束或失败时不等待按 Enter"
|
Write-Host " 结束或失败时不等待按 Enter"
|
||||||
Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
|
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")
|
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
|
||||||
$environmentPath = Join-Path $scriptDir ".env"
|
$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
|
$environmentContent = Get-Content $environmentPath -Raw -Encoding UTF8
|
||||||
if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) {
|
if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) {
|
||||||
& docker inspect easyai-server *> $null
|
& docker inspect easyai-server *> $null
|
||||||
@@ -222,14 +227,7 @@ Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env")
|
|||||||
|
|
||||||
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
|
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
|
||||||
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
|
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
|
||||||
if ($env:UPDATE_RELEASE_VERSION) {
|
Set-ApplicationVersions -Path $environmentPath -Plan $applicationVersionPlan
|
||||||
$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)"
|
|
||||||
}
|
|
||||||
|
|
||||||
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
|
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
|
||||||
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
|
. (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;尚未拉取或重建任何服务" }
|
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")
|
$upArguments = @("up", "-d")
|
||||||
if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices }
|
if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices }
|
||||||
if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") {
|
if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") {
|
||||||
|
|||||||
@@ -20,9 +20,12 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
|
|||||||
echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
|
echo " UPDATE_SERVICES='easyai-server easyai-web model-conversion-worker'"
|
||||||
echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。"
|
echo " 仅拉取并重建指定服务;不重建其依赖。留空保持整组更新。"
|
||||||
echo " UPDATE_RELEASE_VERSION=固定版本"
|
echo " UPDATE_RELEASE_VERSION=固定版本"
|
||||||
echo " 通过脚本固定核心服务和转换 Worker 的镜像版本,不提升 latest。"
|
echo " 通过脚本将已选应用的固定版本持久化到 .env;latest 仅为人工稳定版。"
|
||||||
echo " UPDATE_GATEWAY_VERSION=固定版本 / UPDATE_UPDATER_VERSION=固定版本"
|
echo " UPDATE_GATEWAY_VERSION=固定版本 / UPDATE_UPDATER_VERSION=固定版本"
|
||||||
echo " 仅对已选中的网关或更新服务固定已核验镜像。"
|
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 " UPDATE_ADOPT_LEGACY_CONFIG=1"
|
||||||
echo " 备份旧部署配置并将本地差异迁移为独立覆盖文件。"
|
echo " 备份旧部署配置并将本地差异迁移为独立覆盖文件。"
|
||||||
echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'"
|
echo " UPDATE_RESTORE_REPOSITORY_IMAGES='easyai-web'"
|
||||||
@@ -185,6 +188,8 @@ fi
|
|||||||
# 已安装但没有 updater 的旧部署保留其更新方式;新安装仍默认 managed。
|
# 已安装但没有 updater 的旧部署保留其更新方式;新安装仍默认 managed。
|
||||||
# shellcheck source=scripts/init-security-env.sh
|
# shellcheck source=scripts/init-security-env.sh
|
||||||
. ./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
|
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
|
write_env_value .env SYSTEM_UPDATE_MODE legacy_watchtower
|
||||||
fi
|
fi
|
||||||
@@ -200,21 +205,7 @@ init_server_http_bind_ip .env
|
|||||||
init_security_env .env upgrade
|
init_security_env .env upgrade
|
||||||
bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR"
|
bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR"
|
||||||
|
|
||||||
if [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then
|
apply_application_versions .env
|
||||||
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
|
|
||||||
|
|
||||||
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
|
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
|
||||||
# shellcheck source=scripts/init-public-api-base-url.sh
|
# shellcheck source=scripts/init-public-api-base-url.sh
|
||||||
@@ -342,6 +333,8 @@ if [ -n "${UPDATE_SERVICES:-}" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
verify_application_version_images .env "$DOCKER_COMPOSE_CMD"
|
||||||
|
|
||||||
# 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。
|
# 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。
|
||||||
if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then
|
if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then
|
||||||
sudo ./scripts/check-redis-persistence.sh
|
sudo ./scripts/check-redis-persistence.sh
|
||||||
|
|||||||
Reference in New Issue
Block a user