fix(deploy): 修复新安装与旧部署升级流程 #13

Merged
wangbo merged 2 commits from codex/deployment-flow-repair into main 2026-10-18 00:39:10 +08:00
30 changed files with 1602 additions and 270 deletions
+7 -4
View File
@@ -258,10 +258,13 @@ STATIC_CDN_OSS_PORT=
STATIC_CDN_OSS_USE_SSL=true
STATIC_CDN_FORCE_UPLOAD=false
# 首次启动需要初始化数据库和服务;健康检查成功会立即通过,不必等待宽限期结束。
EASYAI_CORE_START_PERIOD=180s
# ========== 15. 版本 ==========
VERSION=3.29.0.20261014.2
# 应用镜像只使用完整版本号;latest/latest-no-ssr 仅在人工指定版本后提升为稳定版。
# 组件未单独填写时继承 VERSION;update.sh/update.ps1 会持久化实际选中的组件版本。
VERSION=latest
# start/update 默认从正式稳定记录解析组件版本及摘要;不把产品版本套给配套组件。
# VERSION 保留旧入口兼容。组件的 tag@sha256 值由脚本持久化,不能删除摘要。
SERVER_VERSION=
WEB_VERSION=
WS_GATEWAY_VERSION=
@@ -285,7 +288,7 @@ WATCHTOWER_CLEANUP=true
# 核心服务替换后最多等待 5 分钟恢复可用。
UPDATE_HEALTH_TIMEOUT_MS=300000
# 私有镜像仓库登录信息;执行 docker login 后通常位于此处。
DOCKER_CONFIG_FILE=/root/.docker/config.json
DOCKER_CONFIG_FILE=
SYSTEM_UPDATE_INTERNAL_TOKEN=
# 宿主机每六小时分别维护 Watchtower 和 updater;保留 Watchtower 的 updater 更新计划。
INFRASTRUCTURE_AUTO_UPDATE_ENABLED=true
+8
View File
@@ -10,3 +10,11 @@ data/credential-migration/
data/credential-recovery/
data/backups/
data/restores/
# Local runtime state and Python bytecode
__pycache__/
*.pyc
data/deployment-backups/
data/docker-auth/
/data/
/sandbox-data/
+47 -53
View File
@@ -19,7 +19,7 @@ DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.s
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true CERTBOT_EMAIL=ops@example.com ./start.sh
```
Linux IP 模式会自动安装并配置 Nginx,浏览器统一访问 `http://IP`。公网只需放行 TCP 80;前端、API、WebSocket 和 ASG 分别通过 `/`、`/api`、`/socket.io`、`/asg-api` 反向代理,容器上游端口默认仅绑定 `127.0.0.1`。
Linux IP 模式会自动安装并配置 Nginx,浏览器统一访问 `http://IP`。公网只需放行 TCP 80;前端、API、WebSocket 和 ASG 分别通过 `/`、`/api`、`/socket.io`、`/asg-api` 反向代理,容器上游端口默认仅绑定 `127.0.0.1`。首次启动会初始化数据库和服务,核心容器默认有 180 秒启动宽限期,检查成功会立即通过;脚本等待所选服务恢复可用后,还会等待 Nginx 配置生效并核验入口,之后才报告完成。可通过 `DEPLOY_HEALTH_TIMEOUT_SECONDS` / `UPDATE_HEALTH_TIMEOUT_SECONDS` 调整等待时限(默认 600 秒)。
常用变量:
@@ -141,43 +141,43 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
## 更新升级
### 老用户(旧版本部署包)升级教程
### 老用户原目录升级
如果你当前使用的是较早版本的部署包,**无法在旧包目录里直接执行 `git pull`** 来获取配置文件,建议按下面流程升级:
升级保留原安装目录、`.env`、`data/` 和 Docker 数据卷。**不要先执行 `docker compose down`、改名旧目录再重新安装,也不要删除数据卷。**脚本先核对现有容器的项目归属、数据库和上传目录的数据位置,再检查并拉取全部目标镜像,成功后才重建服务;失败会返回非零状态,不能把“收到更新请求”当作完成。
升级前先做可恢复的数据库备份,并确认上传文件、Redis 持久化数据及凭据加密密钥已保留。脚本自动把升级前 `.env*` 和 Compose 配置备份到权限受限的 `data/deployment-backups/update-*`;**这份配置备份不包含数据库,也不能替代数据库备份**。升级不重置管理员密码或轮换已有合格安全密钥。配置中已有的自定义 Compose 项目名会保留;如果现有容器属于另一个项目,或数据库将从旧绑定目录/数据卷切换到另一位置,脚本会在拉取、重建前停止,避免误建空库。
Git 安装的用户,在原目录执行:
1. 先停止并下线旧项目容器:
```bash
cd ~/easyai
docker compose down
./update.sh
```
如果你的环境使用的是旧命令,请改用:
默认读取官方远程发布源中的正式稳定版。Linux/macOS 和 Windows 均保留已有更新模式;没有 updater 的旧部署继续使用原模式,不强制切换。
旧部署包没有 Git、或旧更新脚本无法自更新时,先把新版部署工具克隆到独立临时目录,再让工具在**原安装目录**迁移;不搬迁业务数据:
```bash
docker-compose down
# Ubuntu/Debian:仅旧包配置迁移需要 PyYAML
sudo apt-get install -y python3-yaml
TOOL_DIR=$(mktemp -d /tmp/easyai-upgrade-tool.XXXXXX)
git clone --depth 1 https://git.51easyai.com/wangbo/easyai.git "$TOOL_DIR"
UPDATE_PROJECT_DIR="$HOME/easyai" UPDATE_MODE=full UPDATE_NON_INTERACTIVE=1 \
UPDATE_ADOPT_LEGACY_CONFIG=1 bash "$TOOL_DIR/update.sh"
```
2. 将旧目录重命名备份(示例改名为 `easyai2`):
```bash
cd ~
mv easyai easyai2
```
请把示例中的原安装目录替换成实际路径。迁移先备份将被替换的配置与脚本,将旧 Compose 配置保存为私有覆盖文件,保留端口、数据卷和自定义配置;官方应用镜像改由发布记录选择。自定义镜像、额外覆盖链或无法安全识别的配置会阻止迁移,需要先核对差异。工具目录确认无用后可删除;不要删除原安装目录或备份。
3. 重新执行一键部署命令,按脚本提示完成初始化输入。
如果希望跳过问答,可直接传入变量:
```bash
DEPLOY_ACCESS=ip DEPLOY_IP=服务器IP ./start.sh
# 或
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=false ./start.sh
```
### 稳定版与指定版本
4. 当脚本询问是否启用 HTTPS 时:
- 如果你之前已经有可用证书,填写 `N`(不生成新证书)。
- 只有在需要新申请证书时,才选择启用并生成证书。
新安装、完整更新、仅镜像更新和页面更新默认共同读取官方远程 `system-update/release-manifest.json`,配套组件从匹配的产品记录及随部署工具提交的 `system-update/deployment-components.json` 独立基线解析。仅镜像更新无需先拉取 Git,也不会因本地清单过期而误报已是新版。自定义 `UPDATE_MANIFEST_URL` 的发布源应同时提供 `releases/<产品版本>.json`;离线部署设置 `UPDATE_MANIFEST_SOURCE=local`,由运维同步维护完整记录。主服务、网页、网关、Worker、updater、治理和记忆服务独立发布,**不能把同一个产品版本号拼到所有镜像后面**。managed 模式业务组件和固定配套组件持久化各自的 `tag@sha256`。旧 Watchtower 模式的默认更新保留核心服务官方 `latest` 稳定别名,并在拉取前后核对清单摘要,后续仍能自动获取新版稳定镜像;显式固定版本仅用于受控测试或指定版本操作。updater 默认也保留官方稳定 `latest`,继续接受原有自维护更新。保留旧 `VERSION=latest` 部署的升级入口;不把日常候选版本自动当作稳定版。
5. 完成上述迁移后,后续若有新的配置文件或版本更新,可直接在新的 `easyai` 目录中执行 `./update.sh` 进行更新。
新安装显式选取已登记产品版本可设置 `DEPLOY_RELEASE_VERSION=完整版本号`;升级可设置 `UPDATE_RELEASE_VERSION=完整版本号`。二者都按该产品记录选择组件,保留未变化的网关和 Worker 实际版本。单独测试某个组件时才使用 `UPDATE_SERVER_VERSION` 等参数,并通过 `UPDATE_SERVICES` 明确范围。缺失记录、错误标签、匿名拉取失败或平台不符会在重建前终止。
日常发布只构建并推送完整版本标签,线上与测试环境均通过 `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/覆盖文件。
日常发布只构建并推送完整版本标签,线上与测试环境均通过 `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 和实际使用的必要配套产物。
`latest` / `latest-no-ssr` 仅作为人工稳定版指针。只有用户明确指定版本转稳定版后才运行 `node scripts/promote-stable-release.mjs --version <版本> --confirm-version <同一版本>`(先 `--dry-run`),按固定产物 digest 复制稳定别名、读回并生成稳定清单,再审查和提交;日常 deploy 不调用此入口,也不修改稳定清单。发布工具在修改稳定指针前会运行完整安装镜像预检;也可单独执行 `bash scripts/check-deployment-release.sh --version <产品版本>`。预检逐一验证默认安装所需镜像的匿名可访问性和 linux/amd64 平台,不拉取镜像层、不启动容器。该操作不重编业务镜像、不改业务环境版本。版本记录须包括独立 SSR/no-SSR 和实际使用的必要配套产物。
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
@@ -187,7 +187,7 @@ update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效
- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL`
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式
- 自动拉取最新镜像并重启服务
- 按正式稳定记录或显式选择的产品版本拉取镜像,随后重建服务
- 等待主服务、Web 和 WebSocket 网关恢复可用后才报告成功;拉取失败、启动失败或恢复超时会明确报告失败
新安装默认启用可查询进度的在线更新模式。管理员在页面发起更新后,即使主服务重启导致原请求断开,页面也会根据任务编号继续查询最终结果。存量部署如果没有 `easyai-updater` 容器,更新脚本会保留 Watchtower 模式;已经显式设置 `SYSTEM_UPDATE_MODE=legacy_watchtower` 的环境也保持该模式。只有新安装默认启用 updater。新模式只管理 `easyai-server`、`easyai-web` 和 `ws-gateway`,数据库、Redis、RabbitMQ 等基础服务仍按原部署流程维护。
@@ -207,11 +207,11 @@ WATCHTOWER_CORE_UPDATE_ENABLED=false
WATCHTOWER_UPDATE_TARGET=easyai-updater
SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS=86400
UPDATE_HEALTH_TIMEOUT_MS=300000
DOCKER_CONFIG_FILE=/root/.docker/config.json
DOCKER_CONFIG_FILE=
EASYAI_DEPLOYMENT_SCHEMA_VERSION=2
```
`SYSTEM_UPDATE_INTERNAL_TOKEN` 由 `start.sh` / `update.sh` 自动生成并持久化,不需要手工填写。`UPDATE_HEALTH_TIMEOUT_MS` 控制每个核心服务替换后的恢复等待时间,默认 300000 毫秒。私有镜像仓库需要先执行 `docker login`;更新器与仅负责其自更新的 Watchtower 都会只读挂载 `DOCKER_CONFIG_FILE` 指向的 Docker 登录配置。切换后执行一次 `./update.sh`,确认 `easyai-updater` 以及三个核心服务均处于可用状态。
`SYSTEM_UPDATE_INTERNAL_TOKEN` 由 `start.sh` / `update.sh` 自动生成并持久化,不需要手工填写。`UPDATE_HEALTH_TIMEOUT_MS` 控制每个核心服务替换后的恢复等待时间,默认 300000 毫秒。脚本优先复用现有 Docker 登录配置;匿名安装没有认证文件时自动建立权限受限的空配置文件,避免挂载源被创建成目录。私有镜像仓库需要先执行 `docker login`;更新器与仅负责其自更新的 Watchtower 都会只读挂载 `DOCKER_CONFIG_FILE` 指向的 Docker 登录配置。切换后执行一次 `./update.sh`,确认 `easyai-updater` 以及三个核心服务均处于可用状态。
Watchtower 默认使用 `latest`。`start.sh/update.sh` 自动安装宿主机 cron,`start.ps1/update.ps1` 自动注册 Windows 计划任务;每六小时分别对 Watchtower 和已启用的 updater 执行 `docker compose pull <服务>`、`docker compose up -d --no-deps <服务>` 并确认健康状态。只在镜像或配置有变化时重建,拉取失败不会停止该服务的现有容器。一项维护失败仍继续处理另一项,日志保留各自结果并报告失败;因此 updater 的宿主机维护不依赖 Watchtower 可用。未启用 updater 的 legacy 部署跳过 updater,不自动切换更新模式。Watchtower 的 updater 24 小时更新计划保持不变,无需额外容器。Windows 任务使用当前登录用户,以便访问其 Docker Desktop;Docker 不可用时保留失败日志,下次计划重试。
@@ -267,9 +267,9 @@ UPDATE_RELEASE_VERSION="<指定完整版本号>" ./update.sh
脚本拉取部署仓库后会切换到新版脚本,补齐缺失的转换配置;已有令牌、端口、数据卷和显式付费选择保持原值。指定 `UPDATE_SERVICES` 时只重建这些服务,不重建数据库等依赖;服务名称有误会在拉取镜像前终止。普通更新不指定该变量时保留原来的整组更新行为。转换容器只加入业务网络,不开放主机端口,不连接沙箱网络,不挂载 Docker socket,也不读取整个生产 `.env`。默认限额为 2 CPU、4 GiB、并发 1、单任务 300 秒、临时空间 2 GiB;原模型始终保留,轻量转换失败可使用原模型。
`MODEL_CONVERSION_WORKER_VERSION` 使用已发布的固定版本,未独立填写时继承 `VERSION`;更新脚本将目标组件版本持久化到 `.env`,应用更新不接受 `latest` 或其他浮动标签。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。
`MODEL_CONVERSION_WORKER_VERSION` 使用产品记录中的实际 Worker 版本及摘要,不继承产品 `VERSION`。单独指定组件时只接受固定版本;Worker 使用固定标签和已核验摘要,核心服务的旧 Watchtower 稳定别名按前文规则保留。混元付费通道默认关闭,自建转换失败不自动切换到付费服务。已有 updater 的页面更新仍按稳定清单管理三个核心服务;转换 Worker 的首次安装和配置升级使用上述 `update` 脚本完成。
测试候选版本时使用 `UPDATE_RELEASE_VERSION=3.23.0.20261003`,由更新脚本固定核心服务和转换 Worker 的版本,无需改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。
测试候选版本时使用 `UPDATE_RELEASE_VERSION=<已登记产品版本>`,由更新脚本解析记录中的核心服务和转换 Worker 实际版本,无需手工改动远端 `.env` 或移动 `latest` 标签。版本号不合法时在修改配置前终止。
Linux 旧部署如果因本地 Compose 修改无法拉取仓库,可在检查差异后设置 `UPDATE_ADOPT_LEGACY_CONFIG=1`。脚本需要 Python 3 和 PyYAML;先校验合并配置,再将原始配置和冲突文件备份到 `data/deployment-backups/`,本地差异保存在 `data/deployment-config/legacy-compose.json`,仓库继续管理新增服务和配置。其他业务代码修改、未支持的文件冲突或无法表示的删除会阻止迁移,不启动容器。活动 Nginx 配置不会被改写。
@@ -698,22 +698,16 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1"
> - **或**:以管理员身份打开 PowerShell,执行 `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`,之后可直接运行 `.\start.ps1`
### 启用 HTTPS
1. [更改为你的域名]修改`easyai-proxy.conf`中域名`51easyai.com`为你的域名[可以使用Ctrl+F批量替换51easyai.com为你的域名]
2. [修改.env文件]修改如下两个环境变量为如下的对应的值
首次域名部署直接使用统一入口,脚本会先验证容器,再配置代理和证书,并检查 HTTPS 首页与后端就绪接口:
```bash
NUXT_PUBLIC_BASE_APIURL=/api
NUXT_PUBLIC_BASE_SOCKETURL=wss://<你的域名>/socket.io
SERVER_HTTP_BIND_IP=127.0.0.1
```
3. [首次执行,后续无需重复执行]添加执行权限,命令:
```bash
chmod +x https.sh
```
4. 启用HTTPS
```bash
./https.sh
DEPLOY_ACCESS=domain DEPLOY_DOMAIN=demo.example.com DEPLOY_HTTPS=true \
CERTBOT_EMAIL=ops@example.com ./start.sh
```
已有 HTTP 站点切换 HTTPS 前,备份现有配置,按前文“后端公开 API 地址”同步公开 URL 和 WebSocket 协议,再使用 `https.sh` 的显式域名入口申请证书;自定义代理需要同步检查对应配置。域名解析、TCP 80/443 和证书申请均需在目标环境验收。
### 配置PDF图文解析Markdown功能
如果需要启用PDF图文解析Markdown功能,需要`env.tools`中的OSS环境变量,具体参考文件中的配置说明:自动解析PDF图文,将图片文件上传到OSS,并解析成Markdown格式,并返回
@@ -721,16 +715,17 @@ chmod +x https.sh
## 常见问题
1. 某个服务无法运行
解决方案:重启docker(可以解决99%的问题)
先保留现场并查看具体服务,避免整组停机。`not found` / `MANIFEST_UNKNOWN` 表示引用的标签或摘要不存在;`unauthorized` / `denied` 需要核对镜像匿名访问权限或授权;其他服务显示 `Interrupted` 不能单独证明该镜像缺失。重启 Docker 或反复重试不能修复不存在的标签。
```bash
#
cd easyai && docker compose down && docker compose up -d
#如果提示docker "compose" is not a docker command,则使用下面的命令
cd easyai && docker-compose down && docker-compose up -d
cd ~/easyai
docker compose ps
docker compose logs --tail=100 easyai-server
```
提供诊断信息时只附服务状态和必要错误,不要发送 `.env`、完整 `docker compose config` 或认证文件。安装和升级只有在拉取、启动、健康检查全部完成后才报告成功。
2. 启动时提示`Error: listen EADDRINUSE: address already in use :::3000`
端口占用,请检查端口3000是否被其他程序占用
3. invalid interpolation format for services.mongo.ports.[]. You may need to escape any $ with another $ 等关于docker-compose文件格式的报错
@@ -742,10 +737,9 @@ apt remove docker-compose
./start.sh
```
4. 单独更新docker-compose.yml文件,并更新
```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 更新完成"
```
4. 配置文件需要更新
使用 `./update.sh` 的完整更新模式同步 Compose、脚本与配置迁移;不要只下载一个 Compose 文件替换,避免新旧脚本和变量契约混用。已有本地修改时先审查差异,再使用上面的 `UPDATE_ADOPT_LEGACY_CONFIG=1` 受控迁移入口。
## 官方模型目录
+24 -13
View File
@@ -1,8 +1,13 @@
# 非 host 版本, 容器内部使用桥接通信
# (各项参数均有详细的说明,理论情况下保持默认即可运行)
#version: '3'
x-easyai-release:
easyai-server: ${SERVER_STABLE_DIGEST:-}
easyai-web: ${WEB_STABLE_DIGEST:-}
ws-gateway: ${WS_GATEWAY_STABLE_DIGEST:-}
services:
easyai-web:
platform: linux/amd64
container_name: easyai-web
#更新修改冒号后面的版本号
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${WEB_VERSION:-${VERSION}}
@@ -45,9 +50,9 @@ services:
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3010/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
timeout: 10s
retries: 18
start_period: ${EASYAI_CORE_START_PERIOD:-180s}
# 资源限制,防止内存溢出
deploy:
resources:
@@ -56,6 +61,7 @@ services:
reservations:
memory: 600MB
easyai-server:
platform: linux/amd64
container_name: easyai-server
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${SERVER_VERSION:-${VERSION}} # 阿里云
@@ -164,15 +170,16 @@ services:
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
start_period: ${EASYAI_CORE_START_PERIOD:-180s}
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "10"
model-conversion-worker:
platform: linux/amd64
container_name: easyai-model-conversion-worker
image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-${VERSION}}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:${MODEL_CONVERSION_WORKER_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -224,9 +231,10 @@ services:
max-size: "50m"
max-file: "3"
ws-gateway:
platform: linux/amd64
container_name: easyai-wsgateway # websocket网关
# 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-${VERSION}} # 阿里云
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:${WS_GATEWAY_VERSION:-latest} # 阿里云
labels:
- "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -277,9 +285,9 @@ services:
healthcheck:
test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3012/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
timeout: 10s
retries: 18
start_period: ${EASYAI_CORE_START_PERIOD:-180s}
mongo:
# 镜像见 .env 中 MONGO_IMAGE;4.4 备选地址与用途见 .env.sample 注释
image: ${MONGO_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest}
@@ -301,7 +309,7 @@ services:
# - /usr/local/mongo/data:/data/db
- mongo_data:/data/db # 使用docker volume存储数据,提高性能,避免兼容性问题
healthcheck:
test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/${MONGO_PORT}"]
test: ["CMD", "bash", "-c", "echo > /dev/tcp/localhost/27017"]
interval: 30s
retries: 3
timeout: 10s
@@ -393,7 +401,8 @@ services:
max-size: "100m"
max-file: "10"
easyai-updater:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-${VERSION}}
platform: linux/amd64
image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:${UPDATER_VERSION:-latest}
container_name: easyai-updater
profiles:
- managed-update
@@ -568,8 +577,9 @@ services:
max-size: "100m"
max-file: "10"
easyai-asg:
platform: linux/amd64
container_name: easyai-asg
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-${VERSION}}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:${ASG_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
@@ -600,8 +610,9 @@ services:
reservations:
memory: 128MB
agent-memory:
platform: linux/amd64
container_name: agent-memory
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-${VERSION}}
image: registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:${AMS_VERSION:-latest}
labels:
- "com.centurylinklabs.watchtower.enable=true"
- "com.centurylinklabs.watchtower.scope=${WATCHTOWER_SCOPE:-}"
+179
View File
@@ -0,0 +1,179 @@
function Backup-DeploymentConfig {
param([string]$ProjectRoot)
$backup = Join-Path $ProjectRoot ('data/deployment-backups/update-' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ') + '-' + [Guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Force $backup
if ($env:OS -eq 'Windows_NT') {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $backup /inheritance:r /grant:r "${identity}:(OI)(CI)F" *> $null
if ($LASTEXITCODE -ne 0) { throw '无法保护配置备份目录的访问权限' }
} else { & chmod 700 $backup }
$files = @(Get-ChildItem -Path $ProjectRoot -Filter '.env*' -File | Where-Object { -not $_.Name.EndsWith('.sample') })
foreach ($name in @('docker-compose.yml','docker-compose.override.yml','compose.override.yaml')) {
$file = Join-Path $ProjectRoot $name
if (Test-Path $file -PathType Leaf) { $files += Get-Item $file }
}
foreach ($file in $files) {
$target = Join-Path $backup $file.Name
Copy-Item $file.FullName $target
if ($env:OS -ne 'Windows_NT') { & chmod 600 $target }
}
Write-Host "已备份升级前配置(不含数据库):$backup"
}
function Initialize-DeploymentDockerConfig {
param([string]$ProjectRoot)
$path = Join-Path $ProjectRoot '.env'
$content = Get-Content $path -Raw -Encoding UTF8
$configured = Get-EnvValue $content 'DOCKER_CONFIG_FILE'
if ($configured -and (Test-Path $configured -PathType Leaf)) { return }
if ($configured -and $configured -ne '/root/.docker/config.json') { throw 'DOCKER_CONFIG_FILE 不是可读取的配置文件' }
$dockerConfig = if ($env:DOCKER_CONFIG) { Join-Path $env:DOCKER_CONFIG 'config.json' } else { Join-Path $HOME '.docker/config.json' }
if (Test-Path $dockerConfig -PathType Leaf) {
$content = Set-EnvValue $content 'DOCKER_CONFIG_FILE' $dockerConfig.Replace('\','/')
[System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))
return
}
$fallback = Join-Path $ProjectRoot 'data/docker-auth/config.json'
$null = New-Item -ItemType Directory -Force (Split-Path $fallback -Parent)
if (-not (Test-Path $fallback)) { [System.IO.File]::WriteAllText($fallback, "{}`n", [System.Text.UTF8Encoding]::new($false)) }
if (-not (Test-Path $fallback -PathType Leaf)) { throw '安装目录的镜像认证配置不是文件' }
$content = Set-EnvValue $content 'DOCKER_CONFIG_FILE' $fallback.Replace('\','/')
[System.IO.File]::WriteAllText($path, $content, [System.Text.UTF8Encoding]::new($false))
}
function Test-DeploymentStorage {
param([string[]]$ComposeCommand, [string[]]$Services=@())
$executable = $ComposeCommand[0]
$arguments = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json')
$configuration = & $executable @arguments
if ($LASTEXITCODE -ne 0) { throw '部署配置校验失败,尚未拉取或重建服务' }
$resolved = ($configuration -join "`n") | ConvertFrom-Json
& docker info *> $null
if ($LASTEXITCODE -ne 0) { throw '无法连接 Docker,未检查现有容器和数据位置' }
$stateful = @{ 'mongo'=@('/data/db'); 'redis'=@('/data'); 'rabbitmq'=@('/var/lib/rabbitmq'); 'easyai-pgvector'=@('/var/lib/postgresql','/var/lib/postgresql/data'); 'easyai-server'=@('/app/upload') }
function Get-StoragePath([string]$Value) {
$Value = $Value.Replace('\','/') -replace '^/(run/desktop/mnt/host|host_mnt)/([A-Za-z])/', '$2:/'
$Value = $Value -replace '^/host_mnt/', '/'
return [IO.Path]::GetFullPath($Value).TrimEnd('\','/')
}
foreach ($property in $resolved.services.PSObject.Properties) {
$service = $property.Name
$settings = $property.Value
if (($Services.Count -gt 0 -and $service -notin $Services) -or -not $settings.container_name) { continue }
try { $raw = & docker container inspect $settings.container_name 2>&1; $inspectExit = $LASTEXITCODE }
catch {
if ($_.Exception.Message -match 'No such (container|object)') { continue }
throw "无法核对现有容器 $service;尚未继续部署"
}
if ($inspectExit -ne 0) {
if ($raw -and ($raw -join "`n") -notmatch 'No such (container|object)') { throw "无法核对现有容器 $service" }
continue
}
$existing = @(($raw -join "`n") | ConvertFrom-Json)[0]
if ($existing.Config.Labels.'com.docker.compose.project' -ne $resolved.name) { throw "$service 的现有容器不属于当前 Compose 项目;请核对原安装目录和项目名" }
foreach ($mount in $settings.volumes) {
if ($mount.target -notin $stateful[$service]) { continue }
$old = @($existing.Mounts | Where-Object { $_.Destination -eq $mount.target } | Select-Object -First 1)
$equal = $false
if ($old.Count -gt 0 -and $mount.type -eq 'bind' -and $old[0].Type -eq 'bind') {
$expected = Get-StoragePath $mount.source
$actual = Get-StoragePath $old[0].Source
$comparison = if ($env:OS -eq 'Windows_NT') { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal }
$equal = [string]::Equals($expected,$actual,$comparison) -and (Test-Path $expected -PathType Container)
} elseif ($old.Count -gt 0 -and $mount.type -eq 'volume') {
$definition = $resolved.volumes.PSObject.Properties[$mount.source].Value
$equal = $definition.name -and $old[0].Type -eq 'volume' -and $old[0].Name -eq $definition.name
}
if (-not $equal) { throw "$service 的 $($mount.target) 数据位置将改变;请先备份并按数据迁移步骤处理" }
}
}
Write-Host '✅ 现有容器归属和持久化数据位置检查通过'
}
function Test-DeploymentImages {
param([string[]]$ComposeCommand, [string[]]$Services=@())
$executable = $ComposeCommand[0]
$arguments = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json')
$configuration = & $executable @arguments
if ($LASTEXITCODE -ne 0) { throw '部署配置校验失败,尚未拉取或重建服务' }
$resolved = ($configuration -join "`n") | ConvertFrom-Json
$images = @($resolved.services.PSObject.Properties | Where-Object { $Services.Count -eq 0 -or $_.Name -in $Services } | ForEach-Object { $_.Value.image } | Sort-Object -Unique)
if ($images.Count -eq 0) { throw '没有可检查的部署镜像' }
$anonymousConfig = Join-Path ([IO.Path]::GetTempPath()) ('easyai-anonymous-' + [Guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory $anonymousConfig
try {
foreach ($image in $images) {
$arguments = @()
if ($image.StartsWith('registry.cn-shanghai.aliyuncs.com/comfy-ai/') -or $image.StartsWith('registry.cn-shanghai.aliyuncs.com/easyaigc/')) { $arguments += @('--config',$anonymousConfig) }
$arguments += @('manifest','inspect','--verbose',$image)
$result = & docker @arguments 2>&1
if ($LASTEXITCODE -ne 0) { throw "镜像不可拉取:$image;请核对组件版本、匿名访问权限或网络,尚未重建服务" }
try { $descriptors = @(($result -join "`n") | ConvertFrom-Json) } catch { throw "镜像检查返回无效信息:$image" }
$platforms = @($descriptors | Where-Object { $_.Descriptor.platform.os -eq 'linux' -and $_.Descriptor.platform.architecture -eq 'amd64' })
if ($platforms.Count -eq 0) { throw "镜像没有 linux/amd64 产物:$image" }
foreach ($property in $resolved.services.PSObject.Properties) {
if ($property.Value.image -ne $image) { continue }
$expectedProperty = if ($resolved.'x-easyai-release') { $resolved.'x-easyai-release'.PSObject.Properties[$property.Name] } else { $null }
if ($expectedProperty -and $expectedProperty.Value -and -not @($platforms | Where-Object { $_.Descriptor.digest -eq $expectedProperty.Value }).Count) {
throw "官方稳定别名与发布清单尚未同步:$image;保留当前服务"
}
}
}
} finally { Remove-Item $anonymousConfig -Recurse -Force }
Write-Host "✅ 已核验 $($images.Count) 个部署镜像的可访问性和平台"
}
function Wait-DeploymentServices {
param([string[]]$ComposeCommand, [string[]]$Services=@(), [int]$TimeoutSeconds=600)
if ($TimeoutSeconds -le 0) { throw '健康检查等待秒数必须为正整数' }
$executable = $ComposeCommand[0]
$prefix = @($ComposeCommand | Select-Object -Skip 1)
if ($Services.Count -eq 0) { $Services = @(& $executable @prefix config --services) }
if ($LASTEXITCODE -ne 0 -or $Services.Count -eq 0) { throw '无法确定需要验收的部署服务' }
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$passes = 0
Write-Host "⏳ 等待 $($Services.Count) 个服务恢复可用..."
while ((Get-Date) -lt $deadline) {
$allReady = $true
foreach ($service in $Services) {
$containerId = (& $executable @prefix ps -q $service 2>$null | Select-Object -First 1)
if (-not $containerId) { $allReady = $false; continue }
$state = (& docker inspect --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $containerId 2>$null)
$parts = "$state".Trim().Split('|')
if ($parts.Count -lt 2 -or $parts[0] -ne 'true' -or ($parts[1] -ne 'none' -and $parts[1] -ne 'healthy')) { $allReady = $false }
elseif ($service -eq 'watchtower' -and $parts[1] -eq 'none') {
& docker exec $containerId /watchtower --health-check *> $null
if ($LASTEXITCODE -ne 0) { $allReady = $false }
}
}
if ($allReady) {
$passes++
if ($passes -ge 2) { Write-Host '✅ 所选服务已恢复可用'; return }
Start-Sleep -Seconds 2
} else { $passes=0; Start-Sleep -Seconds 5 }
}
& $executable @prefix ps @Services
throw '服务未在规定时间内恢复可用,部署未完成;请检查所选服务日志'
}
function Test-PulledReleaseImages {
param([string[]]$ComposeCommand, [string[]]$Services=@())
$executable = $ComposeCommand[0]
$arguments = @($ComposeCommand | Select-Object -Skip 1) + @('config','--format','json')
$configuration = & $executable @arguments
if ($LASTEXITCODE -ne 0) { throw '无法读取拉取后的镜像定义' }
$resolved = ($configuration -join "`n") | ConvertFrom-Json
foreach ($property in $resolved.'x-easyai-release'.PSObject.Properties) {
if (-not $property.Value -or ($Services.Count -gt 0 -and $property.Name -notin $Services)) { continue }
$reference = $resolved.services.PSObject.Properties[$property.Name].Value.image
if (-not $reference) { continue }
$repository = $reference -replace ':[^:]+$', ''
$raw = & docker image inspect $reference
if ($LASTEXITCODE -ne 0) { throw '无法检查已拉取的稳定镜像' }
$image = @(($raw -join "`n") | ConvertFrom-Json)[0]
if ("$repository@$($property.Value)" -notin $image.RepoDigests) {
throw "$($property.Name) 拉取后的稳定镜像与目标清单不一致;未重建服务,请重新检查正式发布"
}
}
Write-Host '✅ 拉取后的稳定镜像与目标发布一致'
}
+107 -29
View File
@@ -1,41 +1,108 @@
$script:ApplicationReleaseRoot = Split-Path $PSScriptRoot -Parent
function Read-DeploymentReleaseRecord {
param([string]$Path, [string]$Url='')
if (-not $Url) { return (Get-Content $Path -Raw -Encoding UTF8 | ConvertFrom-Json) }
try {
$address = [Uri]$Url
if ($address.Scheme -notin @('http','https') -or $address.UserInfo) { throw 'Invalid release URL' }
return ((Invoke-WebRequest -UseBasicParsing -Uri $address -TimeoutSec 20 -Headers @{'Accept'='application/json';'Cache-Control'='no-cache'}).Content | ConvertFrom-Json)
} catch { throw '无法读取正式发布源;保留当前服务,不使用历史清单宣称已更新' }
}
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 { $_ })
$selected = @(([string]$env:UPDATE_SERVICES).Trim() -split '\s+' | Where-Object { $_ })
$requested = if ($env:UPDATE_RELEASE_VERSION) { $env:UPDATE_RELEASE_VERSION } else { $env:DEPLOY_RELEASE_VERSION }
if ($requested -and $requested -notmatch '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$') { throw '产品版本必须为完整版本号' }
$relative = if ($requested) { "system-update/releases/$requested.json" } else { 'system-update/release-manifest.json' }
$sourceMode = if ($null -ne $env:UPDATE_MANIFEST_SOURCE) { $env:UPDATE_MANIFEST_SOURCE } else { Get-EnvValue $content 'UPDATE_MANIFEST_SOURCE' }
if (-not $sourceMode) { $sourceMode = 'remote' }
$sourceMode = $sourceMode.Trim().ToLowerInvariant()
if ($sourceMode -notin @('remote','local')) { throw 'UPDATE_MANIFEST_SOURCE 只支持 remote 或 local' }
$releaseUrl = if ($null -ne $env:UPDATE_MANIFEST_URL) { $env:UPDATE_MANIFEST_URL } else { Get-EnvValue $content 'UPDATE_MANIFEST_URL' }
if (-not $releaseUrl) { $releaseUrl = 'https://git.51easyai.com/wangbo/easyai/raw/branch/main/system-update/release-manifest.json' }
try {
$address = [Uri]$releaseUrl
if ($address.Scheme -notin @('http','https') -or $address.UserInfo -or $releaseUrl -match '[\x00-\x1f\\]') { throw 'invalid URL' }
} catch { throw '发布源必须是有效 HTTP(S) 地址,不能内嵌登录凭据或控制字符' }
$manifestPath = Join-Path $script:ApplicationReleaseRoot $relative
if ($sourceMode -eq 'remote' -and -not $requested) {
$manifest = Read-DeploymentReleaseRecord -Url $releaseUrl
} elseif (Test-Path $manifestPath) {
$manifest = Read-DeploymentReleaseRecord -Path $manifestPath
} elseif ($sourceMode -eq 'remote') {
$manifest = Read-DeploymentReleaseRecord -Url ([Uri]::new([Uri]$releaseUrl,"releases/$requested.json").AbsoluteUri)
} else { $manifest = Read-DeploymentReleaseRecord -Path $manifestPath }
$version = [string]$manifest.productVersion
if ($manifest.schemaVersion -ne 1 -or $version -notmatch '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?$' -or $manifest.platform -ne 'linux/amd64' -or (-not $requested -and $manifest.channel -ne 'stable') -or ($requested -and $version -ne $requested)) { throw '发布记录的版本、通道或平台不正确' }
$support = Read-DeploymentReleaseRecord -Path (Join-Path $script:ApplicationReleaseRoot 'system-update/deployment-components.json')
$records = @{}
foreach ($item in $support.components) { $records[$item.key] = $item }
$productPath = Join-Path $script:ApplicationReleaseRoot "system-update/releases/$version.json"
if ((Test-Path $productPath) -or $sourceMode -eq 'remote') {
$product = if (Test-Path $productPath) { Read-DeploymentReleaseRecord -Path $productPath } else {
Read-DeploymentReleaseRecord -Url ([Uri]::new([Uri]$releaseUrl,"releases/$version.json").AbsoluteUri)
}
if ($product.productVersion -ne $version) { throw '配套产物记录与产品版本不一致' }
foreach ($item in $product.components) { $records[$item.key] = $item }
}
foreach ($item in $manifest.components) { $records[$item.key] = $item }
foreach ($item in $manifest.deploymentComponents) { $records[$item.key] = $item }
$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')
@('easyai-server','server-main','SERVER_VERSION','UPDATE_SERVER_VERSION','comfy-ai/comfy-server'),
@('easyai-web','web','WEB_VERSION','UPDATE_WEB_VERSION','comfy-ai/one-ai'),
@('ws-gateway','ws-gateway','WS_GATEWAY_VERSION','UPDATE_GATEWAY_VERSION','easyaigc/wsgateway'),
@('model-conversion-worker','model-conversion-worker','MODEL_CONVERSION_WORKER_VERSION','UPDATE_WORKER_VERSION','easyaigc/model-conversion-worker'),
@('easyai-updater','updater','UPDATER_VERSION','UPDATE_UPDATER_VERSION','easyaigc/easyai-updater'),
@('easyai-asg','service-governance','ASG_VERSION','UPDATE_ASG_VERSION','easyaigc/agent-governance'),
@('agent-memory','memory-service','AMS_VERSION','UPDATE_AMS_VERSION','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 }
$mode = Get-EnvValue $content 'SYSTEM_UPDATE_MODE'
if (-not $mode) { $mode = $env:EASYAI_UPDATE_MODE_HINT }
$legacy = $mode -eq 'legacy_watchtower'
foreach ($entry in $mapping) {
$service,$component,$key,$option,$repository = $entry
$explicit = [Environment]::GetEnvironmentVariable($option)
if ($selected.Count -gt 0 -and $service -notin $selected) {
if ($explicit) { throw "$option 要求在 UPDATE_SERVICES 中指定 $service" }
continue
}
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='' }
$image = "registry.cn-shanghai.aliyuncs.com/$repository"
$stableDigest = ""
$variant = ""
if ($explicit) {
$pattern = '^\d+\.\d+\.\d+\.\d{8}(\.\d+)?'
if ($service -eq 'easyai-web') { $pattern += '(-no-ssr|-hmg-ai|-easyai|-chinacuc)?' }
if ($service -eq 'easyai-updater') { $pattern = '^\d+\.\d+\.\d+(\.\d{8}(\.\d+)?)?' }
if ($explicit -notmatch ($pattern + '(@sha256:[a-f0-9]{64})?$')) { throw "$option 必须是已发布的固定版本,不能使用浮动标签" }
$target = $explicit
} else {
$item = $records[$component]
$oldWeb = Get-EnvValue $content 'WEB_VERSION'
if (-not $oldWeb) { $oldWeb = Get-EnvValue $content 'VERSION' }
if ($service -eq 'easyai-web' -and $oldWeb -match '(-no-ssr|-hmg-ai|-easyai|-chinacuc)(@.*)?$') {
$variant = $Matches[1]
$variantKey = if ($variant -eq '-no-ssr') { 'web-no-ssr' } else { $variant.Substring(1) }
$item = $records[$variantKey]
if (-not $item) { throw "所选发布没有当前网页变体 $variant;请显式指定 UPDATE_WEB_VERSION" }
}
if (-not $item -or $item.image -ne $image -or $item.digest -notmatch '^sha256:[a-f0-9]{64}$') { throw "发布记录缺少有效配套产物 $component" }
$tag = if ($item.tag) { [string]$item.tag } else { 'latest' }
if ($tag -notmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') { throw '配套产物标签不正确' }
$target = if ($service -eq 'easyai-updater' -and $tag -eq 'latest') { $tag } else { "$tag@$($item.digest)" }
if ($legacy -and -not $requested -and $component -in @('server-main','web','ws-gateway')) {
$target = 'latest' + $variant
$stableDigest = [string]$item.digest
}
}
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$env:UPDATE_RELEASE_VERSION; Image='' }
$plan += [PSCustomObject]@{ Service=$service; Key=$key; Version=$target; Image=$image }
if ($component -in @('server-main','web','ws-gateway')) {
$plan += [PSCustomObject]@{ Service=''; Key=$key.Replace('_VERSION','_STABLE_DIGEST'); Version=$stableDigest; Image='' }
}
}
if ($selected.Count -eq 0 -or ('easyai-server' -in $selected -and 'easyai-web' -in $selected)) {
$plan += [PSCustomObject]@{ Service=''; Key='VERSION'; Version=$version; Image='' }
}
return $plan
}
@@ -67,3 +134,14 @@ function Test-ApplicationVersionImages {
}
}
}
function Set-DeploymentReleaseSource {
param([string]$Path)
$content = Get-Content $Path -Raw -Encoding UTF8
if ($null -ne $env:UPDATE_MANIFEST_SOURCE) {
$mode = if ($env:UPDATE_MANIFEST_SOURCE) { $env:UPDATE_MANIFEST_SOURCE.Trim().ToLowerInvariant() } else { 'remote' }
$content = Set-EnvValue $content 'UPDATE_MANIFEST_SOURCE' $mode
}
if ($null -ne $env:UPDATE_MANIFEST_URL) { $content = Set-EnvValue $content 'UPDATE_MANIFEST_URL' $env:UPDATE_MANIFEST_URL }
[System.IO.File]::WriteAllText($Path, $content, [System.Text.UTF8Encoding]::new($false))
}
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""Explicitly adopt an old non-Git Linux package IN PLACE, preserving its data/config.
The source must be a clean Git checkout of the reviewed deployment repository.
Unknown/custom configuration is preserved in an ignored Compose override.
"""
import argparse
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('project', type=Path)
parser.add_argument('source', type=Path)
args = parser.parse_args()
project, source = args.project.resolve(), args.source.resolve()
def git(root, *arguments):
return subprocess.check_output(['git', '-C', str(root), *arguments], text=True).strip()
def main():
try:
import yaml
except ImportError:
raise SystemExit('旧部署包迁移需要 PyYAML;请先安装 python3-yaml,尚未修改部署目录')
if (project / '.git').exists() or project == source:
raise SystemExit('此入口只用于没有 Git 的旧部署包')
env = project / '.env'
compose = project / 'docker-compose.yml'
if not env.is_file() or not compose.is_file() or env.is_symlink() or compose.is_symlink():
raise SystemExit('旧目录缺少 .env 或 docker-compose.yml,不能自动迁移')
content = env.read_text()
if any(line.startswith('COMPOSE_FILE=') and line.split('=', 1)[1].strip().strip('\"\'') for line in content.splitlines()):
raise SystemExit('旧目录已有 COMPOSE_FILE 覆盖,请先核对差异后迁移;未修改配置')
if git(source, 'status', '--porcelain', '--untracked-files=no'):
raise SystemExit('迁移源含未提交改动,请使用已提交的部署仓库')
revision = git(source, 'rev-parse', 'HEAD')
repository = git(source, 'remote', 'get-url', 'origin')
try:
legacy = yaml.safe_load(compose.read_text())
except (yaml.YAMLError, UnicodeError):
raise SystemExit('旧 Compose 无法解析,尚未修改配置或容器')
if not isinstance(legacy, dict) or not {'easyai-server', 'easyai-web'} <= set(legacy.get('services', {})):
raise SystemExit('不能识别旧 Compose 服务,未修改部署目录')
for service in legacy['services'].values():
image = service.get('image', '')
if image.startswith(('registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:',
'registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:',
'registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:',
'registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker:',
'registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:',
'registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance:',
'registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory:')):
service.pop('image')
legacy.pop('version', None)
paths = git(source, 'ls-tree', '-r', '--name-only', 'HEAD').splitlines()
for name in paths:
target = project / name
if target.is_symlink() or os.path.commonpath([str(target.resolve()), str(project)]) != str(project):
raise SystemExit('目标路径包含符号链接或越界路径,未修改部署目录')
if target.exists() and not target.is_file():
raise SystemExit('目标文件与已有目录冲突,未修改部署目录')
if name.startswith(('data/', 'sandbox-data/')) and target.is_file() and target.read_bytes() != (source / name).read_bytes():
raise SystemExit('旧数据目录含有与仓库模板不同的文件,请先核对差异;未覆盖原数据')
# Ordinary Compose overrides APPEND port bindings. Validate the effective
# merge before adopting it; never silently open an extra default port.
with tempfile.TemporaryDirectory(prefix='easyai-package-check-') as temporary:
staging = Path(temporary)
for name in paths:
target = staging / name
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / name, target)
for sample in source.glob('.env*.sample'):
shutil.copyfile(sample, staging / sample.name[:-len('.sample')])
for private in project.glob('.env*'):
if private.is_file() and not private.name.endswith('.sample'):
shutil.copyfile(private, staging / private.name)
shutil.copyfile(compose, staging / 'old-package.yml')
(staging / 'legacy-package.json').write_text(json.dumps(legacy))
compose_command = ['docker', 'compose']
if subprocess.run(compose_command + ['version'], capture_output=True).returncode:
compose_command = ['docker-compose']
def configuration(files):
command = compose_command + ['--project-name', 'easyai-package-check', '--project-directory', str(staging), '--env-file', str(staging / '.env')]
for name in files:
command += ['-f', str(staging / name)]
result = subprocess.run(command + ['config', '--format', 'json'], capture_output=True, text=True)
if result.returncode:
raise SystemExit('旧配置无法与新版安全合并;请检查环境文件和 Compose 版本,原部署未修改')
return json.loads(result.stdout)
original = configuration(['old-package.yml'])
merged = configuration(['docker-compose.yml', 'legacy-package.json'])
for service, settings in original['services'].items():
old_ports = settings.get('ports', [])
new_ports = merged['services'][service].get('ports', [])
if old_ports and old_ports != new_ports:
raise SystemExit(f'服务 {service} 的旧端口无法安全表示为覆盖配置;请先核对差异,原部署未修改')
os.umask(0o077)
backups = project / 'data/deployment-backups'
backups.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix='package-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=backups))
records = []
# Include all files the new repository will replace, plus private settings.
for name in sorted(set(paths) | {p.name for p in project.glob('.env*') if p.is_file()}):
original = project / name
if original.is_file():
saved = backup / 'files' / name
saved.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(original, saved)
saved.chmod(0o600)
records.append({'file': name, 'sha256': hashlib.sha256(saved.read_bytes()).hexdigest()})
(backup / 'backup.json').write_text(json.dumps({'kind': 'old-package-config-only', 'targetCommit': revision, 'files': records}, indent=2) + '\n')
override = project / 'data/deployment-config/legacy-package.json'
override.parent.mkdir(parents=True, exist_ok=True)
override.write_text(json.dumps(legacy, ensure_ascii=False, indent=2) + '\n')
# Create Git metadata without checking out over private settings or data.
git(project, 'init', '-q')
git(project, 'remote', 'add', 'origin', repository)
git(project, 'fetch', '-q', '--depth=1', str(source), 'HEAD')
git(project, 'symbolic-ref', 'HEAD', 'refs/heads/main')
git(project, 'update-ref', 'refs/heads/main', revision)
git(project, 'read-tree', 'HEAD')
git(project, 'config', 'branch.main.remote', 'origin')
git(project, 'config', 'branch.main.merge', 'refs/heads/main')
for name in paths:
destination = project / name
if name.startswith(('data/', 'sandbox-data/')) and destination.exists():
continue
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / name, destination)
with (project / '.git/info/exclude').open('a') as output:
output.write('\n/data/deployment-config/\n/data/deployment-backups/\n')
if content and not content.endswith('\n'):
content += '\n'
content += 'COMPOSE_FILE=docker-compose.yml:data/deployment-config/legacy-package.json\n'
env.write_text(content)
env.chmod(0o600)
print('已在原目录接入部署仓库;保留原 .env、数据目录、数据卷与本地配置。')
print('旧文件备份(不含数据库):' + str(backup))
if __name__ == '__main__':
main()
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Preserve deployment configuration before migrations; this is not a database backup."""
from datetime import datetime, timezone
import hashlib
import json
import os
from pathlib import Path
import shutil
import tempfile
root = Path.cwd()
os.umask(0o077)
parent = root / 'data/deployment-backups'
parent.mkdir(parents=True, exist_ok=True)
backup = Path(tempfile.mkdtemp(prefix='update-' + datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ-'), dir=parent))
files = list(root.glob('.env*')) + [root / name for name in ('docker-compose.yml', 'docker-compose.override.yml', 'compose.override.yaml')]
records = []
for source in files:
if not source.is_file() or source.name.endswith('.sample'):
continue
target = backup / source.name
shutil.copyfile(source, target)
target.chmod(0o600)
records.append({'file': source.name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
(backup / 'backup.json').write_text(json.dumps({'kind': 'deployment-config-only', 'files': records}, indent=2) + '\n')
print('已备份升级前配置(不含数据库):' + str(backup))
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Real anonymous image preflight for the complete README installation, no daemon mutations.
set -euo pipefail
unset UPDATE_SERVICES UPDATE_RELEASE_VERSION UPDATE_SERVER_VERSION UPDATE_WEB_VERSION UPDATE_GATEWAY_VERSION UPDATE_WORKER_VERSION UPDATE_UPDATER_VERSION UPDATE_ASG_VERSION UPDATE_AMS_VERSION
unset UPDATE_MANIFEST_SOURCE UPDATE_MANIFEST_URL
source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
manifest_source=''
while [ "$#" -gt 0 ]; do
case "$1" in
--version) export DEPLOY_RELEASE_VERSION="${2:?缺少版本}"; shift 2 ;;
--manifest) manifest_source="$(cd "$(dirname "${2:?缺少发布记录}")" && pwd)/$(basename "$2")"; shift 2 ;;
*) echo '用法:bash scripts/check-deployment-release.sh [--version 完整版本] [--manifest 发布记录]' >&2; exit 1 ;;
esac
done
if [ -n "$manifest_source" ] && ! [[ "${DEPLOY_RELEASE_VERSION:-}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?$ ]]; then
echo '--manifest 必须同时指定完整 --version' >&2
exit 1
fi
temporary="$(mktemp -d "${TMPDIR:-/tmp}/easyai-release-check.XXXXXX")"
trap 'rm -r "$temporary"' EXIT
cp "$source_root/docker-compose.yml" "$temporary/"
cp -R "$source_root/scripts" "$source_root/system-update" "$temporary/"
if [ -n "$manifest_source" ]; then cp "$manifest_source" "$temporary/system-update/releases/${DEPLOY_RELEASE_VERSION}.json"; fi
for name in .env .env.tools .env.ASG .env.AMS; do cp "$source_root/$name.sample" "$temporary/$name"; done
cd "$temporary"
. ./scripts/init-security-env.sh
. ./scripts/init-application-version-env.sh
write_env_value .env UPDATE_MANIFEST_SOURCE local
plan_application_versions .env
apply_application_versions .env
if docker compose version >/dev/null 2>&1; then compose=(docker compose); else compose=(docker-compose); fi
configuration="$("${compose[@]}" config --format json)"
printf '%s' "$configuration" | python3 ./scripts/verify-deployment-images.py
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Refuse to adopt another Compose project or replace existing persistent storage."""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import sys
STATEFUL = {'mongo': {'/data/db'}, 'redis': {'/data'}, 'rabbitmq': {'/var/lib/rabbitmq'},
'easyai-pgvector': {'/var/lib/postgresql', '/var/lib/postgresql/data'},
'easyai-server': {'/app/upload'}}
def canonical(path):
path = path.replace('\\', '/')
path = re.sub(r'^/(?:run/desktop/mnt/host|host_mnt)/([A-Za-z])/', r'\1:/', path)
if re.match(r'^[A-Za-z]:/', path):
return path.rstrip('/').casefold()
if path.startswith('/host_mnt/'):
path = path[len('/host_mnt'):]
return str(Path(path).resolve()).rstrip('/')
def validate(configuration, existing, selected=()):
for service, settings in configuration['services'].items():
if selected and service not in selected:
continue
container = existing.get(service)
if not container:
continue
project = (container.get('Config', {}).get('Labels') or {}).get('com.docker.compose.project')
if project != configuration['name']:
raise ValueError(f'{service} 的现有容器不属于当前 Compose 项目;请核对原安装目录和项目名')
actual = {mount['Destination']: mount for mount in container.get('Mounts', [])}
for mount in settings.get('volumes', []):
if mount['target'] not in STATEFUL.get(service, set()):
continue
old = actual.get(mount['target'], {})
if mount['type'] == 'bind':
expected = canonical(mount['source'])
equal = old.get('Type') == 'bind' and canonical(old.get('Source', '')) == expected and Path(expected).is_dir()
else:
definition = configuration.get('volumes', {}).get(mount.get('source'), {})
name = definition.get('name')
equal = bool(name) and old.get('Type') == 'volume' and old.get('Name') == name
if not equal:
raise ValueError(f'{service} 的 {mount["target"]} 数据位置将改变;请先备份并按数据迁移步骤处理')
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sudo', action='store_true')
parser.add_argument('--services', nargs='*', default=[])
args = parser.parse_args()
configuration = json.load(sys.stdin)
docker = ['sudo', 'docker'] if args.sudo else ['docker']
if subprocess.run(docker + ['info'], capture_output=True).returncode:
raise ValueError('无法连接 Docker,未检查现有容器和数据位置')
existing = {}
for service, settings in configuration['services'].items():
if args.services and service not in args.services:
continue
# Explicit global names can collide with another installation. Services
# without container_name are already scoped by Compose's project name.
if not settings.get('container_name'):
continue
result = subprocess.run(docker + ['container', 'inspect', settings['container_name']], capture_output=True, text=True)
if result.returncode:
if result.stderr and not re.search('No such (?:container|object)', result.stderr, re.I):
raise ValueError(f'无法核对现有容器 {service},未继续部署')
continue
existing[service] = json.loads(result.stdout)[0]
validate(configuration, existing, args.services)
print('✅ 现有容器归属和持久化数据位置检查通过')
if __name__ == '__main__':
try:
main()
except (ValueError, KeyError, TypeError, OSError) as error:
print('❌ ' + str(error) + ';尚未拉取或重建服务', file=sys.stderr)
sys.exit(1)
+6 -1
View File
@@ -4,7 +4,6 @@
set -euo pipefail
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
expected_dir="$(cd "${project_dir}/data" && pwd -P)/redis"
container="${REDIS_CONTAINER_NAME:-redis}"
if ! docker info >/dev/null 2>&1; then
@@ -16,6 +15,12 @@ if ! docker container inspect "$container" >/dev/null 2>&1; then
exit 0
fi
if [ ! -d "${project_dir}/data" ]; then
echo '❌ 现有 Redis 的原数据目录不存在,停止部署;不会创建空目录替换旧数据' >&2
exit 1
fi
expected_dir="$(cd "${project_dir}/data" && pwd -P)/redis"
mount="$(docker inspect --format '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Source}}{{end}}{{end}}' "$container")"
if [ "$mount" != "bind|$expected_dir" ]; then
echo "❌ 现有 Redis 的 /data 未挂载到 ${expected_dir},停止部署以保护 AOF/RDB 数据" >&2
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# Shared installation/update checks. Never print compose config or credentials.
init_deployment_docker_config() {
local file="$1" configured fallback
configured="$(read_env_value "$file" DOCKER_CONFIG_FILE)"
if [ -n "$configured" ] && [ -f "$configured" ]; then return 0; fi
if [ -n "$configured" ] && [ "$configured" != /root/.docker/config.json ]; then
echo '❌ DOCKER_CONFIG_FILE 不是可读取的配置文件;未启动服务' >&2
return 1
fi
if [ -f "${DOCKER_CONFIG:-$HOME/.docker}/config.json" ]; then
write_env_value "$file" DOCKER_CONFIG_FILE "${DOCKER_CONFIG:-$HOME/.docker}/config.json"
return 0
fi
# Anonymous installations have no ~/.docker/config.json. A missing short
# bind source would otherwise be created by Docker as a DIRECTORY.
fallback="$PWD/data/docker-auth/config.json"
mkdir -p "$(dirname "$fallback")"
chmod 700 "$(dirname "$fallback")"
if [ ! -e "$fallback" ]; then (umask 077; printf '{}\n' > "$fallback"); fi
[ -f "$fallback" ] || { echo '❌ 安装目录的镜像认证配置不是文件' >&2; return 1; }
write_env_value "$file" DOCKER_CONFIG_FILE "$fallback"
}
verify_deployment_images() {
local compose="$1" configuration
shift
configuration="$(sudo $compose config --format json)" || return 1
printf '%s' "$configuration" | python3 ./scripts/verify-deployment-images.py --sudo --services "$@"
}
check_deployment_storage() {
local compose="$1" configuration
shift
configuration="$(sudo $compose config --format json)" || return 1
printf '%s' "$configuration" | python3 ./scripts/check-deployment-storage.py --sudo --services "$@"
}
verify_pulled_release() {
local compose="$1" configuration
shift
configuration="$(sudo $compose config --format json)" || return 1
printf '%s' "$configuration" | python3 ./scripts/verify-pulled-release.py --sudo --services "$@"
}
wait_for_deployment_services() {
local compose="$1" timeout="$2" deadline service container_id state running health all_ready passes=0
shift 2
local services=("$@")
[[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo '❌ 健康检查等待秒数必须为正整数' >&2; return 1; }
if [ "${#services[@]}" -eq 0 ]; then
while IFS= read -r service; do [ -z "$service" ] || services+=("$service"); done < <(sudo $compose config --services)
fi
[ "${#services[@]}" -gt 0 ] || return 1
deadline=$((SECONDS + timeout))
echo "⏳ 等待 ${#services[@]} 个服务恢复可用(最长 ${timeout} 秒)..."
while (( SECONDS < deadline )); do
all_ready=true
for service in "${services[@]}"; do
container_id="$(sudo $compose ps -q "$service" 2>/dev/null || true)"
if [ -z "$container_id" ]; then all_ready=false; continue; fi
state="$(sudo docker inspect --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)"
running="${state%%|*}"; health="${state#*|}"
if [ "$running" != true ] || { [ "$health" != none ] && [ "$health" != healthy ]; }; then
all_ready=false
elif [ "$service" = watchtower ] && [ "$health" = none ]; then
sudo docker exec "$container_id" /watchtower --health-check >/dev/null 2>&1 || all_ready=false
fi
done
if [ "$all_ready" = true ]; then
passes=$((passes + 1))
if [ "$passes" -ge 2 ]; then echo '✅ 所选服务已恢复可用'; return 0; fi
sleep 2
else
passes=0
sleep 5
fi
done
echo '❌ 服务未在规定时间内恢复可用,部署未完成' >&2
sudo $compose ps "${services[@]}" || true
echo "请运行:$compose logs --tail=100 ${services[*]}" >&2
return 1
}
+15 -29
View File
@@ -12,37 +12,23 @@ application_selected() {
[ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" $1 "* ]]
}
init_deployment_release_source() {
local file="$1" mode
if [ "${UPDATE_MANIFEST_SOURCE+x}" = x ]; then
mode="$(printf '%s' "${UPDATE_MANIFEST_SOURCE:-remote}" | tr '[:upper:]' '[:lower:]')"
write_env_value "$file" UPDATE_MANIFEST_SOURCE "$mode"
fi
if [ "${UPDATE_MANIFEST_URL+x}" = x ]; then write_env_value "$file" UPDATE_MANIFEST_URL "$UPDATE_MANIFEST_URL"; fi
}
plan_application_versions() {
local file="$1" service key option image value suffix base old_default
local file="$1" plan entry resolver_root
resolver_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
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
plan="$(python3 "$resolver_root/scripts/resolve-deployment-release.py" --root "$resolver_root" --env-file "$file")" || return 1
while IFS= read -r entry; do
[ -z "$entry" ] || APPLICATION_VERSION_WRITES+=("$entry")
done <<< "$plan"
}
apply_application_versions() {
+4
View File
@@ -110,6 +110,10 @@ function main() {
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'));
// The updater's three-component manifest is not the full first-install set.
// Verify every default README image anonymously before changing stable aliases.
promotionPlan(manifest,options.version,options['confirm-version']);
execFileSync('bash',[`${ROOT}/scripts/check-deployment-release.sh`,'--version',options.version,'--manifest',manifestPath],{stdio:'inherit'});
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));
}
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Resolve a product release to its actual independently published components.
This only reads committed release records; it never changes stable aliases or
chooses a newer candidate automatically. stdout contains version settings only.
"""
import argparse
import json
import os
from pathlib import Path
import re
import sys
from urllib.parse import urljoin, urlsplit
from urllib.request import Request, urlopen
REGISTRY = 'registry.cn-shanghai.aliyuncs.com/'
COMPONENTS = (
('easyai-server', 'server-main', 'SERVER_VERSION', 'UPDATE_SERVER_VERSION', 'comfy-ai/comfy-server'),
('easyai-web', 'web', 'WEB_VERSION', 'UPDATE_WEB_VERSION', 'comfy-ai/one-ai'),
('ws-gateway', 'ws-gateway', 'WS_GATEWAY_VERSION', 'UPDATE_GATEWAY_VERSION', 'easyaigc/wsgateway'),
('model-conversion-worker', 'model-conversion-worker', 'MODEL_CONVERSION_WORKER_VERSION', 'UPDATE_WORKER_VERSION', 'easyaigc/model-conversion-worker'),
('easyai-updater', 'updater', 'UPDATER_VERSION', 'UPDATE_UPDATER_VERSION', 'easyaigc/easyai-updater'),
('easyai-asg', 'service-governance', 'ASG_VERSION', 'UPDATE_ASG_VERSION', 'easyaigc/agent-governance'),
('agent-memory', 'memory-service', 'AMS_VERSION', 'UPDATE_AMS_VERSION', 'easyaigc/agent-memory'),
)
PRODUCT_VERSION = r'\d+\.\d+\.\d+\.\d{8}(?:\.\d+)?'
SUFFIX = r'(?:-no-ssr|-hmg-ai|-easyai|-chinacuc)?'
DIGEST = r'sha256:[a-f0-9]{64}'
DEFAULT_RELEASE_URL = 'https://git.51easyai.com/wangbo/easyai/raw/branch/main/system-update/release-manifest.json'
def env_values(path):
result = {}
for line in path.read_text(encoding='utf-8-sig').splitlines():
match = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)$', line)
if match:
result.setdefault(match[1], match[2].strip().strip('\"\''))
return result
def load(path):
try:
return json.loads(path.read_text(encoding='utf-8-sig'))
except (OSError, ValueError) as error:
raise ValueError(f'无法读取发布记录 {path.name};请完整更新部署仓库后重试') from error
def remote_record(url):
parts = urlsplit(url)
if parts.scheme not in ('http', 'https') or not parts.netloc or parts.username or parts.password:
raise ValueError('发布源必须是 HTTP(S) 地址,不能在地址中嵌入登录凭据')
try:
with urlopen(Request(url, headers={'Accept': 'application/json', 'Cache-Control': 'no-cache'}), timeout=20) as response:
content = response.read(1024 * 1024 + 1)
if len(content) > 1024 * 1024:
raise ValueError('oversized record')
return json.loads(content)
except Exception as error:
raise ValueError('无法读取正式发布源;保留当前服务,不使用历史清单宣称已更新') from error
def resolve(root, values, selection, options):
selected = set(selection.split())
requested = options.get('UPDATE_RELEASE_VERSION') or options.get('DEPLOY_RELEASE_VERSION')
if requested and not re.fullmatch(PRODUCT_VERSION, requested):
raise ValueError('产品版本必须为完整版本号,不能使用 latest/dev 或路径')
mode = (options.get('UPDATE_MANIFEST_SOURCE', values.get('UPDATE_MANIFEST_SOURCE')) or 'remote').strip().lower()
if mode not in ('local', 'remote'):
raise ValueError('UPDATE_MANIFEST_SOURCE 只支持 remote 或 local')
release_url = (options.get('UPDATE_MANIFEST_URL', values.get('UPDATE_MANIFEST_URL')) or DEFAULT_RELEASE_URL).strip()
address = urlsplit(release_url)
if (address.scheme not in ('http', 'https') or not address.netloc or address.username or address.password
or any(ord(char) < 32 for char in release_url) or '\\' in release_url):
raise ValueError('发布源必须是有效 HTTP(S) 地址,不能内嵌登录凭据或控制字符')
path = root / 'system-update' / ('releases/' + requested + '.json' if requested else 'release-manifest.json')
if mode == 'remote' and not requested:
manifest = remote_record(release_url)
elif path.exists():
manifest = load(path)
elif mode == 'remote':
manifest = remote_record(urljoin(release_url, 'releases/' + requested + '.json'))
else:
manifest = load(path)
version = manifest.get('productVersion', '')
if (manifest.get('schemaVersion') != 1 or not re.fullmatch(PRODUCT_VERSION, version)
or manifest.get('platform') != 'linux/amd64'
or manifest.get('channel') not in (('versioned', 'stable') if requested else ('stable',))):
raise ValueError('发布记录的版本、通道或平台不正确,未修改配置或容器')
if requested and version != requested:
raise ValueError('发布记录与指定产品版本不一致')
# Independently maintained services use the reviewed baseline shipped with
# this deployment tool. Product-specific records override it when needed.
support = load(root / 'system-update/deployment-components.json')
records = {item['key']: item for item in support['components']}
# Older updater manifests intentionally contain only its three supported
# components. Use the matching product record for Worker / web variants.
product_path = root / 'system-update/releases' / (version + '.json')
if product_path.exists() or mode == 'remote':
product = (load(product_path) if product_path.exists() else remote_record(urljoin(release_url, 'releases/' + version + '.json')))
if product.get('productVersion') != version:
raise ValueError('配套产物记录与产品版本不一致')
records.update({item['key']: item for item in product['components']})
records.update({item['key']: item for item in manifest['components']})
records.update({item['key']: item for item in manifest.get('deploymentComponents', [])})
plan = []
legacy = (values.get('SYSTEM_UPDATE_MODE') or options.get('EASYAI_UPDATE_MODE_HINT')) == 'legacy_watchtower'
for service, component, key, option, repository in COMPONENTS:
explicit = options.get(option, '')
if selected and service not in selected:
if explicit:
raise ValueError(f'{option} 要求在 UPDATE_SERVICES 中指定 {service}')
continue
image = REGISTRY + repository
stable_digest = ''
if explicit:
pattern = PRODUCT_VERSION + (SUFFIX if service == 'easyai-web' else '')
if service == 'easyai-updater':
pattern = r'\d+\.\d+\.\d+(?:\.\d{8}(?:\.\d+)?)?'
if not re.fullmatch(pattern + '(?:@' + DIGEST + ')?', explicit):
raise ValueError(f'{option} 必须是已发布的固定版本,不能使用浮动标签')
target = explicit
else:
item = records.get(component)
old_web = values.get('WEB_VERSION') or values.get('VERSION', '')
variant = re.search(r'(-no-ssr|-hmg-ai|-easyai|-chinacuc)(?:@.*)?$', old_web)
if service == 'easyai-web' and variant:
variant_key = {'-no-ssr': 'web-no-ssr'}.get(variant[1], variant[1][1:])
item = records.get(variant_key)
if not item:
raise ValueError(f'所选发布没有当前网页变体 {variant[1]};请显式指定 UPDATE_WEB_VERSION')
if not item or item.get('image') != image or not re.fullmatch(DIGEST, item.get('digest', '')):
raise ValueError(f'发布记录缺少有效配套产物 {component},未修改配置或容器')
tag = item.get('tag') or 'latest'
if not re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}', tag):
raise ValueError(f'配套产物标签不正确:{component}')
# A stable alias is allowed only WITH its immutable recorded digest.
# The updater is infrastructure with an existing independent
# Watchtower/cron maintenance contract. Keep its official stable
# alias so old installations can still receive executor updates.
target = tag if service == 'easyai-updater' and tag == 'latest' else tag + '@' + item['digest']
if legacy and not requested and component in ('server-main', 'web', 'ws-gateway'):
# Legacy Watchtower must keep following the official stable
# aliases. Pinning its containers to a digest strands updates.
target = 'latest' + (variant[1] if service == 'easyai-web' and variant else '')
stable_digest = item['digest']
plan.append({'Service': service, 'Key': key, 'Version': target, 'Image': image})
if component in ('server-main', 'web', 'ws-gateway'):
plan.append({'Service': '', 'Key': key.replace('_VERSION', '_STABLE_DIGEST'), 'Version': stable_digest, 'Image': ''})
# Compose companion defaults are independent; changing VERSION cannot
# silently repoint an unselected gateway / worker anymore.
if not selected or {'easyai-server', 'easyai-web'} <= selected:
plan.append({'Service': '', 'Key': 'VERSION', 'Version': version, 'Image': ''})
return plan
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parent.parent)
parser.add_argument('--env-file', type=Path, required=True)
parser.add_argument('--format', choices=('json', 'env'), default='env')
args = parser.parse_args()
try:
plan = resolve(args.root, env_values(args.env_file), os.environ.get('UPDATE_SERVICES', ''), os.environ)
if args.format == 'json':
print(json.dumps(plan, ensure_ascii=False))
else:
for item in plan:
print(item['Key'] + '=' + item['Version'])
except (ValueError, KeyError, TypeError) as error:
print('❌ ' + str(error), file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
+1 -1
View File
@@ -211,7 +211,7 @@ assert manifest["schemaVersion"] == 1
assert manifest["channel"] == "stable"
assert manifest["platform"] == "linux/amd64"
assert manifest["managedUpdateAllowed"] is True
assert manifest["rollbackSafe"] is True
assert type(manifest["rollbackSafe"]) is bool # reviewed policy may forbid rollback
assert manifest["minimumUpdaterVersion"] == "1.1.0"
assert manifest["minimumDeploymentSchemaVersion"] == 2
assert int(manifest["requiredFreeBytes"]) > 0
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Cross-shell release behavior: stable defaults, independent versions, legacy compatibility."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
root = Path(__file__).resolve().parent.parent
cases = [
('new install', 'VERSION=latest\n', {}, True),
('legacy latest', 'VERSION=latest\nMODEL_CONVERSION_WORKER_VERSION=latest\n', {}, True),
('watchtower aliases', 'VERSION=latest\nSYSTEM_UPDATE_MODE=legacy_watchtower\n', {}, True),
('watchtower variant', 'VERSION=latest-no-ssr\nSYSTEM_UPDATE_MODE=legacy_watchtower\n', {}, True),
('legacy pinned', 'VERSION=3.29.0.20261014.2\n', {}, True),
('recorded product', 'VERSION=latest\n', {'UPDATE_RELEASE_VERSION': '3.29.0.20261014.2'}, True),
('selected server', 'VERSION=latest\n', {'UPDATE_SERVICES': 'easyai-server', 'UPDATE_SERVER_VERSION': '3.29.0.20261014.2'}, True),
('web variant', 'VERSION=latest-no-ssr\n', {}, True),
('updater independent semver', 'VERSION=latest\n', {'UPDATE_SERVICES': 'easyai-updater', 'UPDATE_UPDATER_VERSION': '1.1.0'}, True),
('unknown product', 'VERSION=latest\n', {'UPDATE_RELEASE_VERSION': '9.9.9.20990101'}, False),
('invalid product path', 'VERSION=latest\n', {'UPDATE_RELEASE_VERSION': '../latest'}, False),
('invalid scope', 'VERSION=latest\n', {'UPDATE_SERVICES': 'easyai-server', 'UPDATE_WEB_VERSION': '3.29.0.20261014.2'}, False),
('floating component rejected', 'VERSION=latest\n', {'UPDATE_WEB_VERSION': 'latest'}, False),
]
with tempfile.TemporaryDirectory(prefix='easyai-release-resolution-') as temporary:
path = Path(temporary) / '.env'
for name, content, options, succeeds in cases:
path.write_text(content + 'UPDATE_MANIFEST_SOURCE=local\nCONFIG_JWT_SECRET=private-fixture-must-not-be-printed\n')
before = path.read_bytes()
env = {k: v for k, v in os.environ.items() if not k.startswith(('UPDATE_', 'DEPLOY_'))}
env.update(options)
bash = ['python3', str(root / 'scripts/resolve-deployment-release.py'), '--env-file', str(path), '--format', 'json']
ps = ['pwsh', '-NoProfile', '-NonInteractive', '-Command',
'. $args[0]; . $args[1]; @(Get-ApplicationVersionPlan -Path $args[2]) | ConvertTo-Json -Compress',
str(root / 'scripts/Initialize-SecurityEnv.ps1'), str(root / 'scripts/Initialize-ApplicationVersions.ps1'), str(path)]
# -Command positional arguments are parsed as PowerShell expressions;
# use a saved script to preserve paths with spaces on both shells.
script = Path(temporary) / 'plan.ps1'
script.write_text('param($Security,$Versions,$Path)\n$ErrorActionPreference="Stop"\n. $Security\n. $Versions\n@(Get-ApplicationVersionPlan -Path $Path) | ConvertTo-Json -Compress\n')
ps = ['pwsh', '-NoProfile', '-NonInteractive', '-File', str(script),
str(root / 'scripts/Initialize-SecurityEnv.ps1'), str(root / 'scripts/Initialize-ApplicationVersions.ps1'), str(path)]
plans = []
for command in (bash, ps):
result = subprocess.run(command, env=env, capture_output=True, text=True, errors='replace', timeout=30)
assert (result.returncode == 0) == succeeds, (name, result.stdout, result.stderr)
assert 'private-fixture-must-not-be-printed' not in result.stdout + result.stderr
if succeeds:
plan = json.loads(result.stdout)
plan = plan if isinstance(plan, list) else [plan]
plans.append({entry['Key']: entry['Version'] for entry in plan})
assert path.read_bytes() == before, 'planning must not mutate private settings'
if succeeds:
assert plans[0] == plans[1], (name, plans)
if name in ('new install', 'legacy latest', 'legacy pinned', 'recorded product'):
assert plans[0]['WS_GATEWAY_VERSION'].startswith('3.25.0.20261009@sha256:')
assert plans[0]['MODEL_CONVERSION_WORKER_VERSION'].startswith('3.25.0.20261009.2@sha256:')
assert plans[0]['UPDATER_VERSION'] == 'latest', 'keep the existing updater maintenance channel'
if name == 'selected server':
assert set(plans[0]) == {'SERVER_VERSION', 'SERVER_STABLE_DIGEST'}
if name.startswith('watchtower'):
assert plans[0]['SERVER_VERSION'] == plans[0]['WS_GATEWAY_VERSION'] == 'latest'
assert plans[0]['SERVER_STABLE_DIGEST'].startswith('sha256:')
assert plans[0]['WEB_VERSION'] == ('latest-no-ssr' if name.endswith('variant') else 'latest')
if name == 'web variant':
assert '-no-ssr@sha256:' in plans[0]['WEB_VERSION']
print(name + ': passed in Bash planner and PowerShell')
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Both command-line shells honor a live stable source even when Git's snapshot differs."""
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import subprocess
import tempfile
from threading import Thread
source = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix='easyai-release-source-') as temporary:
root = Path(temporary)
manifest = json.loads((source/'system-update/releases/3.29.0.20261014.2.json').read_text())
manifest['channel'] = 'stable'
manifest['components'] = [item for item in manifest['components'] if item['key'] in {'server-main','web','ws-gateway'}]
(root/'release-manifest.json').write_text(json.dumps(manifest))
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *args): pass
server = ThreadingHTTPServer(('127.0.0.1',0),partial(Handler,directory=str(root)))
thread = Thread(target=server.serve_forever,daemon=True)
thread.start()
script = root/'plan.ps1'
script.write_text('param($Security,$Versions,$Path)\n$ErrorActionPreference="Stop"\n. $Security\n. $Versions\n@(Get-ApplicationVersionPlan -Path $Path) | ConvertTo-Json -Compress\n')
env = {k:v for k,v in os.environ.items() if not k.startswith(('UPDATE_','DEPLOY_'))}
try:
for mode, filename, expected in [('remote','release-manifest.json','3.29.0.20261014.2'),('remote','missing.json',None),('local','missing.json','3.30.2.20261017')]:
path = root/'.env'
path.write_text(f'VERSION=latest\nUPDATE_MANIFEST_SOURCE={mode}\nUPDATE_MANIFEST_URL=http://127.0.0.1:{server.server_port}/{filename}?token=synthetic-secret-must-not-appear\n')
before = path.read_bytes()
commands = [['python3',str(source/'scripts/resolve-deployment-release.py'),'--env-file',str(path),'--format','json'],
['pwsh','-NoProfile','-NonInteractive','-File',str(script),str(source/'scripts/Initialize-SecurityEnv.ps1'),str(source/'scripts/Initialize-ApplicationVersions.ps1'),str(path)]]
for command in commands:
result = subprocess.run(command,env=env,capture_output=True,text=True,timeout=90)
assert 'synthetic-secret-must-not-appear' not in result.stdout+result.stderr
assert (result.returncode==0)==bool(expected),(mode,filename,result.stdout,result.stderr)
if expected:
plan=json.loads(result.stdout)
values={entry['Key']:entry['Version'] for entry in plan}
assert values['VERSION']==expected
assert values['SERVER_VERSION'].startswith(expected+'@sha256:')
assert path.read_bytes()==before
print(mode+'/'+filename+': both shells selected the authoritative source without stale fallback or credential logging')
finally:
server.shutdown()
server.server_close()
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
"""Cross-shell safeguards for real Compose volume names and existing bind paths."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
source = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix='easyai-storage-check-') as temporary:
root = Path(temporary)
(root / 'upload').mkdir()
(root / 'compose.yml').write_text('''name: fixture-project
services:
mongo:
container_name: fixture-mongo
image: mongo:7
volumes: [database:/data/db]
easyai-server:
container_name: fixture-server
image: alpine:3
volumes: [./upload:/app/upload]
volumes:
database:
name: existing-database
''')
configuration = subprocess.check_output(['docker','compose','config','--format','json'], cwd=root, text=True)
(root / 'config.json').write_text(configuration)
binaries = root / 'bin'
binaries.mkdir()
docker = binaries / 'docker'
docker.write_text('''#!/usr/bin/env python3
import json,os,sys
from pathlib import Path
args=sys.argv[1:]
if args[:2]==['compose','config']: print(Path('config.json').read_text());sys.exit(0)
if args[:1]==['info']:sys.exit(0)
if args[:2]==['container','inspect']:
project='another-project' if os.environ['STORAGE_CASE']=='wrong-project' else 'fixture-project'
mount={'Type':'volume','Name':'old-database' if os.environ['STORAGE_CASE']=='changed-volume' else 'existing-database','Destination':'/data/db'}
if args[-1]=='fixture-server':mount={'Type':'bind','Source':str(Path('upload').resolve()),'Destination':'/app/upload'}
print(json.dumps([{'Config':{'Labels':{'com.docker.compose.project':project}},'Mounts':[mount]}]));sys.exit(0)
sys.exit(1)
''')
docker.chmod(0o755)
script = root / 'check.ps1'
script.write_text('param($Runtime)\n$ErrorActionPreference="Stop"\n. $Runtime\n$selection = @($env:STORAGE_SELECTED -split " " | Where-Object { $_ })\nTest-DeploymentStorage -ComposeCommand @("docker","compose") -Services $selection\n')
for case, selected, success in [('same-storage','',True),('wrong-project','',False),('changed-volume','',False),('changed-volume','easyai-server',True),('missing-bind','',False)]:
if case == 'missing-bind': (root / 'upload').rmdir()
env = {**os.environ, 'PATH':str(binaries)+os.pathsep+os.environ['PATH'], 'STORAGE_CASE':case, 'STORAGE_SELECTED':selected}
commands = [(['python3',str(source/'scripts/check-deployment-storage.py'),'--services',*selected.split()], configuration),
(['pwsh','-NoProfile','-NonInteractive','-File',str(script),str(source/'scripts/DeploymentRuntime.ps1')], None)]
for command, stdin in commands:
result = subprocess.run(command, input=stdin, cwd=root, env=env, capture_output=True, text=True, timeout=90)
assert (result.returncode == 0) == success, (case, selected, result.stdout, result.stderr)
print(case + '/' + (selected or 'all') + ': passed in Python/Bash and PowerShell')
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Exercise the actual installer with real Compose parsing and failing Docker transports."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
root = Path(__file__).resolve().parent.parent
real_docker = shutil.which('docker')
with tempfile.TemporaryDirectory(prefix='easyai-install-failures-') as temporary:
base = Path(temporary)
for phase in ('storage', 'manifest', 'pull', 'health'):
project = base / phase
project.mkdir()
for name in ('scripts', 'system-update'):
shutil.copytree(root / name, project / name, ignore=shutil.ignore_patterns('__pycache__'))
for name in ('start.sh', 'docker-compose.yml', 'easyai-proxy.conf.sample'):
shutil.copy2(root / name, project / name)
for sample in root.glob('.env*.sample'):
shutil.copy2(sample, project / sample.name)
sample = project / '.env.sample'
sample.write_text(sample.read_text().replace('UPDATE_MANIFEST_SOURCE=remote', 'UPDATE_MANIFEST_SOURCE=local'))
binaries = project / 'bin'
binaries.mkdir()
commands = project / 'commands.jsonl'
docker = binaries / 'docker'
docker.write_text('''#!/usr/bin/env python3
import json,os,subprocess,sys
args=sys.argv[1:]
with open(os.environ['TEST_COMMANDS'],'a') as log: log.write(json.dumps(args)+'\\n')
if args[:1]==['--config']: args=args[2:]
if args[:2]==['compose','config'] or args[:2]==['compose','version']:
sys.exit(subprocess.call([os.environ['REAL_DOCKER'],*args]))
if args[:2]==['manifest','inspect']:
if os.environ['TEST_PHASE']=='manifest': print('manifest unknown',file=sys.stderr);sys.exit(1)
print(json.dumps({'Descriptor':{'platform':{'os':'linux','architecture':'amd64'}}}));sys.exit(0)
if args[:2]==['compose','pull']: sys.exit(18 if os.environ['TEST_PHASE']=='pull' else 0)
if args[:3]==['compose','ps','-q']: print('test-'+args[-1]);sys.exit(0)
if args[:1]==['inspect']:
if '--format' in args: print('true|unhealthy');sys.exit(0)
sys.exit(1)
if args[:2]==['container','inspect']:
if os.environ['TEST_PHASE']=='storage' and args[-1]=='easyai-server':
print(json.dumps([{'Config':{'Labels':{'com.docker.compose.project':'unrelated-project'}},'Mounts':[]}]))
sys.exit(0)
sys.exit(1)
sys.exit(0)
''')
docker.chmod(0o755)
for name, code in [('sudo', '#!/bin/sh\nexec "$@"\n'), ('curl', '#!/bin/sh\nexit 0\n')]:
path = binaries / name
path.write_text(code)
path.chmod(0o755)
env = {k: v for k, v in os.environ.items() if not k.startswith(('UPDATE_', 'DEPLOY_', 'COMPOSE_'))}
env.update({'PATH': str(binaries) + os.pathsep + env['PATH'], 'REAL_DOCKER': real_docker,
'TEST_COMMANDS': str(commands), 'TEST_PHASE': phase, 'CI': 'true',
'DEPLOY_ACCESS': 'ip', 'DEPLOY_IP': '127.0.0.1', 'DEPLOY_NON_INTERACTIVE': '1',
'DEPLOY_HEALTH_TIMEOUT_SECONDS': '1'})
result = subprocess.run(['bash', 'start.sh'], cwd=project, env=env, capture_output=True, text=True, errors='replace', timeout=120)
assert result.returncode != 0, (phase, result.stdout, result.stderr)
assert '应用启动成功' not in result.stdout and '部署完成' not in result.stdout, result.stdout
calls = [json.loads(line) for line in commands.read_text().splitlines()]
if phase in ('storage', 'manifest'):
assert not any(call[:2] in (['compose', 'pull'], ['compose', 'up']) for call in calls)
if phase == 'pull':
assert ['compose', 'pull'] in calls, (result.stdout, result.stderr, calls)
assert not any(call[:2] == ['compose', 'up'] for call in calls)
if phase == 'health':
assert ['compose', 'up', '-d'] in calls
assert '部署未完成' in result.stdout + result.stderr
print(phase + ': installer failed without reporting success or continuing to the next stage')
+46 -14
View File
@@ -22,22 +22,28 @@ selected = ['easyai-server', 'easyai-web', 'model-conversion-worker']
def run(args, cwd, env=None):
return subprocess.run(args, cwd=cwd, env=env, text=True, encoding='utf-8', errors='backslashreplace', capture_output=True, timeout=35)
return subprocess.run(args, cwd=cwd, env=env, text=True, encoding='utf-8', errors='backslashreplace', capture_output=True, timeout=90)
def fixture(base):
root = base / 'easyai'
root.mkdir()
for name in ['scripts', 'update.sh', 'update.ps1', '.env.sample', 'docker-compose.yml']:
for name in ['scripts', 'system-update', 'update.sh', 'update.ps1', '.env.sample', 'docker-compose.yml']:
path = source / name
if path.is_dir():
shutil.copytree(path, root / name)
else:
shutil.copy2(path, root / name)
# Synthetic immutable product records keep these transport-only cases
# independent from the current official release and its actual registry.
for version in ['3.23.0.20261003', '3.25.0.20261009.2', '3.25.0.20261009.3']:
components = [{'key': key, 'image': 'registry.cn-shanghai.aliyuncs.com/'+image, 'tag': version, 'digest': 'sha256:'+'a'*64}
for key,image in [('server-main','comfy-ai/comfy-server'),('web','comfy-ai/one-ai'),('ws-gateway','easyaigc/wsgateway'),('model-conversion-worker','easyaigc/model-conversion-worker')]]
(root / 'system-update/releases' / (version+'.json')).write_text(json.dumps({'schemaVersion':1,'channel':'versioned','productVersion':version,'platform':'linux/amd64','components':components}))
(root / 'scripts/install-infrastructure-update-task.sh').write_text('#!/bin/sh\nexit 0\n')
(root / 'scripts/Install-InfrastructureUpdateTask.ps1').write_text('# Scheduler transport is verified separately.\n')
(root / 'data').mkdir()
(root / '.env').write_text('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 / '.env').write_text('UPDATE_MANIFEST_SOURCE=local\nVERSION=3.23.0.20261003\nMODEL_CONVERSION_WORKER_VERSION=3.23.0.20261003\nSYSTEM_UPDATE_MODE=legacy_watchtower\nNUXT_PUBLIC_BASE_APIURL=http://127.0.0.1:3001\nCONFIG_JWT_SECRET=existing-jwt-secret-that-is-long-enough\nWS_AUTH_WS_TICKET_SECRET=existing-ticket-secret-that-is-long-enough\n')
(root / '.gitignore').write_text('.env\n*.log\n')
commands = base / 'commands.jsonl'
commands.touch()
@@ -48,7 +54,18 @@ def fixture(base):
import json, os, sys
args = sys.argv[1:]
with open(os.environ['UPDATE_TEST_COMMANDS'], 'a') as output: output.write(json.dumps(args)+'\\n')
if args[:2] == ['container', 'inspect']: sys.exit(0 if os.environ.get('UPDATE_TEST_LEGACY_REDIS') == '1' else 1)
if args[:1] == ['--config']: args = args[2:]
values = dict(line.split('=',1) for line in open('.env').read().splitlines() if '=' in line and not line.startswith('#'))
expected = {'comfy-ai/comfy-server':'SERVER_STABLE_DIGEST','comfy-ai/one-ai':'WEB_STABLE_DIGEST','easyaigc/wsgateway':'WS_GATEWAY_STABLE_DIGEST'}
if args[:2] == ['manifest', 'inspect']:
digest = next((values.get(key,'') for repo,key in expected.items() if '/'+repo+':' in args[-1]), '')
if os.environ.get('UPDATE_TEST_STABLE_CHANGED') == 'before': digest='sha256:'+'b'*64
print(json.dumps({'Descriptor':{'digest':digest,'platform':{'os':'linux','architecture':'amd64'}}})); sys.exit(0)
if args[:2] == ['image','inspect']:
ref=args[-1]; digest=next((values.get(key,'') for repo,key in expected.items() if '/'+repo+':' in ref), '')
if os.environ.get('UPDATE_TEST_STABLE_CHANGED') == 'after': digest='sha256:'+'b'*64
print(json.dumps([{'RepoDigests':[ref.rsplit(':',1)[0]+'@'+digest]}]));sys.exit(0)
if args[:2] == ['container', 'inspect']: sys.exit(0 if args[-1] == 'redis' and os.environ.get('UPDATE_TEST_LEGACY_REDIS') == '1' else 1)
if args[:1] == ['inspect']:
if '--format' in args:
if args[-1] == 'redis': print('volume|legacy-redis-data'); sys.exit(0)
@@ -60,9 +77,14 @@ if args[:3] == ['compose', 'config', '--services']: print('easyai-server\\neasya
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'}}}))
wanted = [args[-1]] if args[-1] in names else list(names)
services = {}
for name in wanted:
key,image = names[name]
actual = 'registry.cn-shanghai.aliyuncs.com/'+image+':'+('local-override' if os.environ.get('UPDATE_TEST_IMAGE_OVERRIDE') == '1' else values.get(key, 'latest'))
services[name] = {'image':actual}
services['dependency'] = {'image':'redis:7-alpine'}
print(json.dumps({'services': services,'x-easyai-release':{name:values.get(key,'') for name,key in [('easyai-server','SERVER_STABLE_DIGEST'),('easyai-web','WEB_STABLE_DIGEST'),('ws-gateway','WS_GATEWAY_STABLE_DIGEST')]}}))
elif args[:3] == ['compose', 'ps', '-q']: print('container-' + args[-1])
sys.exit(0)
''')
@@ -71,7 +93,7 @@ sys.exit(0)
p = binaries / name
p.write_text(code)
p.chmod(0o755)
env = {**os.environ, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'CI': 'true', 'UPDATE_NO_WAIT': '1', 'UPDATE_MODE': 'image', 'UPDATE_SERVICES': ' '.join(selected), 'UPDATE_TEST_COMMANDS': str(commands), 'UPDATE_TEST_LEGACY_REDIS': '1'}
env = {**{k:v for k,v in os.environ.items() if not k.startswith(('UPDATE_', 'DEPLOY_'))}, 'PATH': str(binaries) + os.pathsep + os.environ['PATH'], 'CI': 'true', 'UPDATE_NO_WAIT': '1', 'UPDATE_MODE': 'image', 'UPDATE_SERVICES': ' '.join(selected), 'UPDATE_TEST_COMMANDS': str(commands), 'UPDATE_TEST_LEGACY_REDIS': '1'}
return root, commands, env
@@ -105,6 +127,14 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
result = run(command, root, env)
assert result.returncode == 0, result.stdout + result.stderr
assert_selected(root, log, result.stdout)
for phase in ('before','after'):
log.write_text('')
result = run(command, root, {**env, 'UPDATE_TEST_STABLE_CHANGED':phase})
assert result.returncode != 0, result.stdout + result.stderr
assert not any(c[:2] == ['compose','up'] for c in calls(log))
if phase == 'before': assert not any(c[:2] == ['compose','pull'] for c in calls(log))
else: assert ['compose','pull',*selected] in calls(log)
log.write_text('')
original = (root / '.env').read_bytes()
result = run(command, root, env)
assert result.returncode == 0, result.stdout + result.stderr
@@ -114,9 +144,10 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
(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))
assert result.returncode == 0, result.stdout + result.stderr
assert_selected(root, log, result.stdout)
migrated = dict(line.split('=',1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert migrated['SERVER_VERSION'] == 'latest' and migrated['SERVER_STABLE_DIGEST'].startswith('sha256:')
(root / '.env').write_bytes(original)
log.write_text('')
result = run(command, root, {**env, 'UPDATE_TEST_IMAGE_OVERRIDE':'1'})
@@ -131,7 +162,8 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
result = run(command, root, {**env, 'UPDATE_RELEASE_VERSION': '3.23.0.20261003'})
assert result.returncode == 0, result.stdout + result.stderr
values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert values['VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION'] == '3.23.0.20261003'
assert values['VERSION'] == '3.23.0.20261003'
assert values['MODEL_CONVERSION_WORKER_VERSION'].startswith('3.23.0.20261003@sha256:')
log.write_text('')
result = run(command, root, {**env, 'UPDATE_SERVICES': 'easyai-server missing-service'})
assert result.returncode != 0
@@ -146,7 +178,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
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['SERVER_VERSION'].startswith('3.25.0.20261009.2@sha256:')
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)
@@ -155,7 +187,7 @@ with tempfile.TemporaryDirectory(prefix='easyai-conversion-update-') as temporar
assert result.returncode == 0, result.stdout + result.stderr
core_values = dict(line.split('=', 1) for line in (root / '.env').read_text().splitlines() if '=' in line and not line.startswith('#'))
assert core_values['VERSION'] == '3.25.0.20261009.3'
assert core_values['MODEL_CONVERSION_WORKER_VERSION'] == values['MODEL_CONVERSION_WORKER_VERSION']
assert core_values['MODEL_CONVERSION_WORKER_VERSION'] == after['MODEL_CONVERSION_WORKER_VERSION']
assert ['compose', 'pull', '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']])
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""Adopt a real non-Git directory and resolve the resulting Compose with Docker CLI."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import yaml
source = Path(__file__).resolve().parent.parent
def run(arguments, root, check=True):
return subprocess.run(arguments, cwd=root, capture_output=True, text=True, check=check, timeout=40)
with tempfile.TemporaryDirectory(prefix='easyai-package-adoption-') as temporary:
base = Path(temporary)
template = base / 'tool'
template.mkdir()
for name in ['docker-compose.yml', '.gitignore']:
shutil.copy2(source / name, template / name)
for sample in source.glob('.env*.sample'):
shutil.copy2(sample, template / sample.name)
for command in [['git','init','-q'], ['git','config','user.name','Deployment fixture'], ['git','config','user.email','fixture@example.invalid'], ['git','add','.'], ['git','commit','-qm','test: 部署模板'], ['git','remote','add','origin','https://example.invalid/deployment.git']]:
run(command, template)
for kind in ('compatible', 'conflicting-port'):
project = base / kind
project.mkdir()
for sample in source.glob('.env*.sample'):
shutil.copy2(sample, project / sample.name.removesuffix('.sample'))
private = (project / '.env').read_text() + '\nEXISTING_PRIVATE_SETTING=must-stay-private\n'
(project / '.env').write_text(private)
original = yaml.safe_load((source / 'docker-compose.yml').read_text())
original['name'] = 'original-customer-project'
original['services']['easyai-server']['environment'].append('EXISTING_OPTION=keep')
if kind == 'conflicting-port':
original['services']['easyai-web']['ports'] = ['127.0.0.1:39111:3010']
(project / 'docker-compose.yml').write_text(yaml.safe_dump(original, sort_keys=False))
marker = project / 'data/upload/existing-file.txt'
marker.parent.mkdir(parents=True)
marker.write_text('existing upload must remain')
original_compose = (project / 'docker-compose.yml').read_bytes()
result = run(['python3', str(source / 'scripts/adopt-deployment-directory.py'), str(project), str(template)], project, check=False)
assert 'must-stay-private' not in result.stdout + result.stderr
assert marker.read_text() == 'existing upload must remain'
if kind == 'compatible':
assert result.returncode == 0, result.stdout + result.stderr
assert (project / '.env').read_text().startswith(private)
assert (project / '.git').is_dir()
resolved = json.loads(run(['docker','compose','config','--format','json'], project).stdout)
assert resolved['name'] == 'original-customer-project'
assert resolved['services']['easyai-server']['environment']['EXISTING_OPTION'] == 'keep'
assert len(resolved['services']['easyai-web']['ports']) == 1
backups = list((project / 'data/deployment-backups').glob('package-*'))
assert len(backups) == 1
assert (backups[0] / 'files/.env').read_text() == private
assert (backups[0] / 'files/docker-compose.yml').read_bytes() == original_compose
assert os.stat(backups[0]).st_mode & 0o777 == 0o700
assert not run(['git','status','--porcelain'], project).stdout
else:
assert result.returncode != 0 and '端口' in result.stdout + result.stderr
assert not (project / '.git').exists()
assert (project / '.env').read_text() == private
assert (project / 'docker-compose.yml').read_bytes() == original_compose
print(kind + ': data/config preserved and unsafe merge rejected before mutation')
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Run the installer's actual proxy verification with deterministic HTTP transports."""
import os
from pathlib import Path
import subprocess
import tempfile
source = Path(__file__).resolve().parent.parent
with tempfile.TemporaryDirectory(prefix='easyai-proxy-entry-') as temporary:
root = Path(temporary)
script = (source / 'start.sh').read_text().rsplit('main "$@"', 1)[0]
(root / 'functions.sh').write_text(script)
curl = root / 'curl'
curl.write_text('''#!/usr/bin/env python3
import json,os,sys
with open(os.environ['CURL_CALLS'],'a') as out:out.write(json.dumps(sys.argv[1:])+'\\n')
if os.environ['HTTP_CASE']=='home-error':sys.exit(22)
if any('/api/health/update-ready' in arg for arg in sys.argv):
if os.environ['HTTP_CASE']=='delayed-reload' and open(os.environ['CURL_CALLS']).read().count('/api/health/update-ready')==1:
print('<html>redirect</html>\\n301');sys.exit(0)
print(json.dumps({'status':'ok','service':'wrong-upstream' if os.environ['HTTP_CASE']=='wrong-upstream' else 'easyai-server-main'})+'\\n200')
else: print('200',end='')
''')
curl.chmod(0o755)
for name, mode, succeeds in [('home-error', 'ip', False), ('wrong-upstream', 'domain', False), ('healthy', 'ip', True), ('healthy', 'https', True), ('delayed-reload', 'domain', True)]:
log = root / 'curl.jsonl'
log.write_text('')
env = {**os.environ, 'PATH': str(root) + os.pathsep + os.environ['PATH'], 'HTTP_CASE': name, 'CURL_CALLS': str(log), 'DEPLOY_PROXY_TIMEOUT_SECONDS':'8' if succeeds else '1'}
run = '. ./functions.sh; DEPLOY_MODE="$1"; DEPLOY_IP=192.0.2.10; DEPLOY_DOMAIN=deployment.example; DEPLOY_HTTPS=false; if [ "$1" = https ]; then DEPLOY_MODE=domain; DEPLOY_HTTPS=true; fi; verify_proxy_entry'
result = subprocess.run(['bash', '-c', run, 'fixture', mode], cwd=root, env=env, capture_output=True, text=True, timeout=15)
assert (result.returncode == 0) == succeeds, (name, mode, result.stdout, result.stderr)
calls = log.read_text()
if mode == 'ip':
assert 'Host: 192.0.2.10' in calls
if mode == 'https':
assert '--resolve' in calls and 'deployment.example:443:127.0.0.1' in calls
assert '--insecure' not in calls and '"-k"' not in calls
print(name + '/' + mode + ': passed')
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Verify the resolved installation images, without pulling layers or using ACR credentials."""
import argparse
from concurrent.futures import ThreadPoolExecutor
import json
import subprocess
import sys
import tempfile
STABLE_KEYS = {'easyai-server': 'SERVER_STABLE_DIGEST', 'easyai-web': 'WEB_STABLE_DIGEST', 'ws-gateway': 'WS_GATEWAY_STABLE_DIGEST'}
def verify(image, docker, config, expected=None):
# Official installation images must be available to a new anonymous user.
# Never reuse a maintainer's credential helper as evidence of public access.
official = image.startswith(('registry.cn-shanghai.aliyuncs.com/comfy-ai/',
'registry.cn-shanghai.aliyuncs.com/easyaigc/'))
command = docker + (['--config', config] if official else [])
try:
result = subprocess.run(command + ['manifest', 'inspect', '--verbose', image],
capture_output=True, text=True, timeout=90)
if result.returncode:
message = result.stderr.lower()
reason = ('标签或摘要不存在' if any(s in message for s in ('not found', 'manifest unknown', 'no such manifest'))
else '未开放匿名拉取' if any(s in message for s in ('unauthorized', 'denied', 'authentication required'))
else '仓库不可访问,请检查网络或仓库状态')
raise ValueError(reason)
manifests = json.loads(result.stdout)
manifests = manifests if isinstance(manifests, list) else [manifests]
platforms = [m.get('Descriptor', {}).get('platform', {}) for m in manifests]
if not any(p.get('os') == 'linux' and p.get('architecture') == 'amd64' for p in platforms):
raise ValueError('没有 linux/amd64 产物')
if expected and not any(m.get('Descriptor', {}).get('digest') == expected
and m.get('Descriptor', {}).get('platform', {}).get('architecture') == 'amd64'
for m in manifests):
raise ValueError('官方稳定别名与发布清单尚未同步;保留当前服务')
return image, None
except (subprocess.TimeoutExpired, OSError):
return image, '镜像检查超时或 Docker 不可用'
except (ValueError, KeyError, TypeError) as error:
return image, str(error) if isinstance(error, ValueError) and not isinstance(error, json.JSONDecodeError) else '仓库返回了无效的镜像信息'
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sudo', action='store_true')
parser.add_argument('--services', nargs='*', default=[])
args = parser.parse_args()
configuration = json.load(sys.stdin)
images = sorted({value['image'] for service, value in configuration['services'].items()
if not args.services or service in args.services})
expected = {value['image']: configuration.get('x-easyai-release', {}).get(service)
for service, value in configuration['services'].items() if service in STABLE_KEYS}
if not images:
parser.error('没有可检查的部署镜像')
docker = ['sudo', 'docker'] if args.sudo else ['docker']
with tempfile.TemporaryDirectory(prefix='easyai-anonymous-registry-') as config:
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(lambda image: verify(image, docker, config, expected.get(image)), images))
failures = [(image, error) for image, error in results if error]
for image, error in failures:
print(f'❌ {image}: {error}', file=sys.stderr)
if failures:
print('镜像检查失败,尚未拉取或重建服务;请核对发布记录和组件实际版本。', file=sys.stderr)
return 1
print(f'✅ 已核验 {len(images)} 个部署镜像的可访问性和 linux/amd64 平台')
return 0
if __name__ == '__main__':
sys.exit(main())
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Check legacy stable aliases AFTER pull, before any container is recreated."""
import argparse
import json
import subprocess
import sys
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--sudo', action='store_true')
parser.add_argument('--services', nargs='*', default=[])
args = parser.parse_args()
configuration = json.load(sys.stdin)
docker = ['sudo','docker'] if args.sudo else ['docker']
keys = {'easyai-server':'SERVER_STABLE_DIGEST','easyai-web':'WEB_STABLE_DIGEST','ws-gateway':'WS_GATEWAY_STABLE_DIGEST'}
for service, key in keys.items():
if args.services and service not in args.services:
continue
settings = configuration['services'].get(service)
if not settings:
continue
digest = configuration.get('x-easyai-release', {}).get(service)
if not digest:
continue
reference = settings['image']
repository = reference.rsplit(':', 1)[0]
result = subprocess.run(docker + ['image','inspect',reference],capture_output=True,text=True)
try:
images = json.loads(result.stdout)
matches = result.returncode == 0 and repository+'@'+digest in images[0].get('RepoDigests', [])
except (ValueError, IndexError, KeyError):
matches = False
if not matches:
print(f'❌ {service} 拉取后的稳定镜像与目标清单不一致;未重建服务,请重新检查正式发布',file=sys.stderr)
sys.exit(1)
print('✅ 拉取后的稳定镜像与目标发布一致')
+14
View File
@@ -345,17 +345,31 @@ function Start-Services {
$useComposeV2 = $false
try { & docker compose version *> $null; if ($LASTEXITCODE -eq 0) { $useComposeV2 = $true } } catch { }
. (Join-Path $script:Root 'scripts/Initialize-ApplicationVersions.ps1')
. (Join-Path $script:Root 'scripts/DeploymentRuntime.ps1')
$plan = @(Get-ApplicationVersionPlan -Path (Join-Path $script:Root '.env'))
Set-ApplicationVersions -Path (Join-Path $script:Root '.env') -Plan $plan
Set-DeploymentReleaseSource -Path (Join-Path $script:Root '.env')
Initialize-DeploymentDockerConfig -ProjectRoot $script:Root
$composeCommand = if ($useComposeV2) { @('docker','compose') } else { @('docker-compose') }
Test-DeploymentStorage -ComposeCommand $composeCommand
Test-ApplicationVersionImages -Plan $plan -ComposeCommand $composeCommand
Test-DeploymentImages -ComposeCommand $composeCommand
if ($useComposeV2) {
docker compose pull
if ($LASTEXITCODE -ne 0) { Fail "docker compose pull failed." }
Test-PulledReleaseImages -ComposeCommand $composeCommand
docker compose up -d
if ($LASTEXITCODE -ne 0) { Fail "docker compose up failed." }
} else {
docker-compose pull
if ($LASTEXITCODE -ne 0) { Fail "docker-compose pull failed." }
Test-PulledReleaseImages -ComposeCommand $composeCommand
docker-compose up -d
if ($LASTEXITCODE -ne 0) { Fail "docker-compose up failed." }
}
$timeout = if ($env:DEPLOY_HEALTH_TIMEOUT_SECONDS) { [int]$env:DEPLOY_HEALTH_TIMEOUT_SECONDS } else { 600 }
Wait-DeploymentServices -ComposeCommand $composeCommand -TimeoutSeconds $timeout
Ok "EasyAI started"
}
+66 -6
View File
@@ -544,13 +544,71 @@ configure_http_nginx_proxy() {
start_services() {
echo ""
echo "🚀 启动 EasyAI 服务..."
. ./scripts/init-application-version-env.sh
. ./scripts/deployment-runtime.sh
plan_application_versions .env
apply_application_versions .env
init_deployment_release_source .env
init_deployment_docker_config .env
local compose="docker compose"
if ! docker compose version &>/dev/null; then compose="docker-compose"; fi
check_deployment_storage "$compose"
verify_application_version_images .env "$compose"
verify_deployment_images "$compose"
sudo ./scripts/check-redis-persistence.sh
if docker compose version &>/dev/null; then
sudo docker compose pull && sudo docker compose up -d
else
sudo docker-compose pull && sudo docker-compose up -d
if ! sudo $compose pull; then
echo '❌ 镜像拉取失败,尚未启动服务' >&2
return 1
fi
echo "🎉 EasyAI 应用启动成功"
verify_pulled_release "$compose"
if ! sudo $compose up -d; then
echo '❌ 容器启动失败,部署未完成' >&2
return 1
fi
wait_for_deployment_services "$compose" "${DEPLOY_HEALTH_TIMEOUT_SECONDS:-600}"
echo '✅ 应用服务健康检查通过,继续检查访问入口'
}
# Verify the host proxy too: healthy containers do not prove the public route.
verify_proxy_entry() {
case "$DEPLOY_MODE" in ip|domain) ;; *) return 0 ;; esac
local public_host response
if [ "$DEPLOY_MODE" = ip ]; then public_host="$DEPLOY_IP"; else public_host="$DEPLOY_DOMAIN"; fi
local curl_options=(--fail --silent --show-error --max-time 10)
local origin='http://127.0.0.1'
if [ "$DEPLOY_HTTPS" = true ]; then
curl_options+=(--resolve "${public_host}:443:127.0.0.1")
origin="https://${public_host}"
else
curl_options+=(-H "Host: ${public_host}")
fi
local timeout="${DEPLOY_PROXY_TIMEOUT_SECONDS:-60}" deadline homepage_status api_status
[[ "$timeout" =~ ^[1-9][0-9]*$ ]] || { echo '❌ 入口检查等待秒数必须为正整数' >&2; return 1; }
deadline=$((SECONDS + timeout))
# nginx -s reload returns before its new workers have accepted requests.
# A previous vhost can briefly answer with a redirect during replacement.
while (( SECONDS < deadline )); do
homepage_status="$(curl "${curl_options[@]}" -w '%{http_code}' "${origin}/" -o /dev/null 2>/dev/null || true)"
if [ "$homepage_status" = 200 ]; then
response="$(curl "${curl_options[@]}" -w '\n%{http_code}' "${origin}/api/health/update-ready" 2>/dev/null || true)"
api_status="${response##*$'\n'}"
response="${response%$'\n'*}"
if [ "$api_status" = 200 ] && printf '%s' "$response" | python3 -c '
import json,sys
try:
value=json.load(sys.stdin)
sys.exit(0 if value.get("status")=="ok" and value.get("service")=="easyai-server-main" else 1)
except (ValueError, AttributeError):
sys.exit(1)
'; then
echo '✅ 同机 Nginx 首页和后端入口检查通过'
return 0
fi
fi
sleep 2
done
echo '❌ Nginx 首页或后端公开入口未在规定时间内就绪,部署未完成' >&2
return 1
}
# ==================== 执行 HTTPS 配置 ====================
@@ -563,7 +621,8 @@ run_https_setup() {
export EASYAI_PROXY_CONF="${DEPLOY_DOMAIN}.conf"
bash ./https.sh
else
echo "⚠️ 未找到 https.sh,请手动配置 HTTPS"
echo '❌ 未找到 https.sh,无法完成请求的 HTTPS 部署' >&2
return 1
fi
fi
}
@@ -675,6 +734,7 @@ main() {
configure_http_nginx_proxy
export DEPLOY_DOMAIN
run_https_setup
verify_proxy_entry
fi
echo ""
+31
View File
@@ -0,0 +1,31 @@
{
"schemaVersion": 1,
"platform": "linux/amd64",
"description": "独立发布的安装配套产物。产品发布记录中的同名组件优先;每次变更必须核验匿名拉取、平台与摘要。",
"components": [
{
"key": "model-conversion-worker",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/model-conversion-worker",
"tag": "3.25.0.20261009.2",
"digest": "sha256:c281a3ad7ae2ae9a2e47411fb565e5fa1c35a756c09d1e281d01803ed090932e"
},
{
"key": "updater",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater",
"tag": "latest",
"digest": "sha256:0200bfc143e45ad5795bfbe172f291e1faeac9685b3f68bc6dad0948febff868"
},
{
"key": "service-governance",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/agent-governance",
"tag": "latest",
"digest": "sha256:4a77cfc85caab7668d37d6f807353b5c959c8d7d9bcb82218bc425f422e16f37"
},
{
"key": "memory-service",
"image": "registry.cn-shanghai.aliyuncs.com/easyaigc/agent-memory",
"tag": "latest",
"digest": "sha256:d56a60a0e8a3a65835a76de34160faa50147c215defa2c2dd1f03f3f09eeeb8a"
}
]
}
+17 -51
View File
@@ -205,7 +205,15 @@ if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
$environmentPath = Join-Path $scriptDir ".env"
. (Join-Path $scriptDir "scripts/Initialize-ApplicationVersions.ps1")
if (-not (Get-EnvValue (Get-Content $environmentPath -Raw -Encoding UTF8) 'SYSTEM_UPDATE_MODE')) {
& docker inspect easyai-server *> $null
$existingServer = $LASTEXITCODE -eq 0
& docker inspect easyai-updater *> $null
if ($existingServer -and $LASTEXITCODE -ne 0) { $env:EASYAI_UPDATE_MODE_HINT = 'legacy_watchtower' }
}
try { $applicationVersionPlan = @(Get-ApplicationVersionPlan -Path $environmentPath) } catch { Write-Err $_.Exception.Message }
. (Join-Path $scriptDir 'scripts/DeploymentRuntime.ps1')
Backup-DeploymentConfig -ProjectRoot $scriptDir
$environmentContent = Get-Content $environmentPath -Raw -Encoding UTF8
if (-not (Get-EnvValue $environmentContent "SYSTEM_UPDATE_MODE")) {
& docker inspect easyai-server *> $null
@@ -228,6 +236,7 @@ Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env")
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
Set-ApplicationVersions -Path $environmentPath -Plan $applicationVersionPlan
Set-DeploymentReleaseSource -Path $environmentPath
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
@@ -303,57 +312,8 @@ function Invoke-Compose {
}
function Wait-CoreServices {
$timeoutSeconds = 300
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_HEALTH_TIMEOUT_SECONDS)) {
if (-not [int]::TryParse($env:UPDATE_HEALTH_TIMEOUT_SECONDS, [ref]$timeoutSeconds) -or $timeoutSeconds -le 0) {
Write-Err "UPDATE_HEALTH_TIMEOUT_SECONDS 必须是正整数,当前为: $($env:UPDATE_HEALTH_TIMEOUT_SECONDS)"
}
}
$services = if ($updateServices.Count -gt 0) {
$updateServices
} elseif ($availableServices -contains "model-conversion-worker") {
@("easyai-server", "ws-gateway", "easyai-web", "model-conversion-worker")
} else {
@("easyai-server", "ws-gateway", "easyai-web")
}
$deadline = (Get-Date).AddSeconds($timeoutSeconds)
Write-Step "⏳ 正在等待核心服务恢复可用(最长 $timeoutSeconds 秒)..."
while ((Get-Date) -lt $deadline) {
$allReady = $true
foreach ($service in $services) {
if ($hasComposeV2) {
$containerId = (& docker compose ps -q $service 2>$null | Select-Object -First 1)
} else {
$containerId = (& docker-compose ps -q $service 2>$null | Select-Object -First 1)
}
if ([string]::IsNullOrWhiteSpace($containerId)) {
$allReady = $false
continue
}
$state = (& docker inspect --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $containerId 2>$null)
$parts = "$state".Trim().Split('|')
if ($parts.Count -lt 2 -or $parts[0] -ne "true" -or ($parts[1] -ne "none" -and $parts[1] -ne "healthy")) {
$allReady = $false
}
}
if ($allReady) {
Write-Ok "核心服务已恢复可用"
return
}
Start-Sleep -Seconds 5
}
Write-Host "❌ 更新后核心服务未在 $timeoutSeconds 秒内恢复可用" -ForegroundColor Red
if ($hasComposeV2) {
docker compose ps @services
} else {
docker-compose ps @services
}
Write-Err "请检查核心服务日志,确认失败原因后重试"
$timeout = if ($env:UPDATE_HEALTH_TIMEOUT_SECONDS) { [int]$env:UPDATE_HEALTH_TIMEOUT_SECONDS } else { 600 }
Wait-DeploymentServices -ComposeCommand $composeCommand -Services $updateServices -TimeoutSeconds $timeout
}
# ==================== 更新并启动 ====================
@@ -371,6 +331,10 @@ if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SERVICES)) {
}
}
$composeCommand = if ($hasComposeV2) { @("docker","compose") } else { @("docker-compose") }
. (Join-Path $scriptDir 'scripts/DeploymentRuntime.ps1')
Initialize-DeploymentDockerConfig -ProjectRoot $scriptDir
Test-DeploymentStorage -ComposeCommand $composeCommand -Services $updateServices
Test-DeploymentImages -ComposeCommand $composeCommand -Services $updateServices
try { Test-ApplicationVersionImages -Plan $applicationVersionPlan -ComposeCommand $composeCommand } catch { Write-Err $_.Exception.Message }
$upArguments = @("up", "-d")
if ($updateServices.Count -gt 0) { $upArguments += @("--no-deps") + $updateServices }
@@ -380,11 +344,13 @@ if ($updateServices.Count -eq 0 -or $updateServices -contains "redis") {
if ($hasComposeV2) {
docker compose pull @updateServices
if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
Test-PulledReleaseImages -ComposeCommand $composeCommand -Services $updateServices
docker compose @upArguments
if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
} else {
docker-compose pull @updateServices
if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
Test-PulledReleaseImages -ComposeCommand $composeCommand -Services $updateServices
docker-compose @upArguments
if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
}
+17 -65
View File
@@ -114,22 +114,6 @@ if [ -n "${UPDATE_RELEASE_VERSION:-}" ] && ! [[ "$UPDATE_RELEASE_VERSION" =~ ^[0
exit 1
fi
# 配套服务可独立更新;固定版本不得改变未选中服务的环境。
for option in UPDATE_GATEWAY_VERSION UPDATE_UPDATER_VERSION; do
value="${!option:-}"
[ -n "$value" ] || continue
if ! [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?$ ]]; then
echo "❌ $option 格式不正确;尚未修改配置或容器" >&2
exit 1
fi
service=ws-gateway
[ "$option" != UPDATE_UPDATER_VERSION ] || service=easyai-updater
if ! [[ " ${UPDATE_SERVICES:-} " == *" $service "* ]]; then
echo "❌ $option 要求在 UPDATE_SERVICES 中指定 $service;尚未修改配置或容器" >&2
exit 1
fi
done
# 拉取整个仓库更新
if [ "$SKIP_REPO_UPDATE" = false ]; then
echo ""
@@ -137,12 +121,13 @@ if [ "$SKIP_REPO_UPDATE" = false ]; then
echo "📥 拉取仓库最新代码"
echo "==========================="
if [ ! -d .git ]; then
echo "❌ 当前目录不是 Git 仓库,无法执行 git pull"
echo " 请使用 git clone 克隆项目后即可使用 update.sh 更新"
echo " 克隆命令: git clone https://git.51easyai.com/wangbo/easyai && cd easyai"
if [ "$(git rev-parse --show-toplevel 2>/dev/null || true)" != "$SCRIPT_DIR" ]; then
if [ "${UPDATE_ADOPT_LEGACY_CONFIG:-0}" != 1 ] || [ "$SOURCE_DIR" = "$SCRIPT_DIR" ]; then
echo '❌ 旧部署包尚未接入 Git。请使用 README 中的原目录迁移入口;不要删除或改名现有数据目录。' >&2
exit 1
fi
python3 "$SOURCE_DIR/scripts/adopt-deployment-directory.py" "$SCRIPT_DIR" "$SOURCE_DIR"
fi
before_revision="$(git rev-parse HEAD)"
if [ "${UPDATE_ADOPT_LEGACY_CONFIG:-0}" = "1" ]; then
@@ -189,7 +174,11 @@ fi
# shellcheck source=scripts/init-security-env.sh
. ./scripts/init-security-env.sh
. ./scripts/init-application-version-env.sh
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
export EASYAI_UPDATE_MODE_HINT=legacy_watchtower
fi
plan_application_versions .env
python3 ./scripts/backup-deployment-config.py
if [ -z "$(read_env_value .env SYSTEM_UPDATE_MODE)" ] && docker inspect easyai-server >/dev/null 2>&1 && ! docker inspect easyai-updater >/dev/null 2>&1; then
write_env_value .env SYSTEM_UPDATE_MODE legacy_watchtower
fi
@@ -206,6 +195,7 @@ init_security_env .env upgrade
bash ./scripts/init-infrastructure-update-env.sh "$SCRIPT_DIR"
apply_application_versions .env
init_deployment_release_source .env
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
# shellcheck source=scripts/init-public-api-base-url.sh
@@ -272,52 +262,11 @@ $DOCKER_COMPOSE_CMD version
echo "🎉 Docker 和 Docker Compose 已就绪!"
# Installation and update share image/public-access and health checks.
. ./scripts/deployment-runtime.sh
init_deployment_docker_config .env
wait_for_core_services() {
local timeout_seconds="${UPDATE_HEALTH_TIMEOUT_SECONDS:-300}"
local deadline=$((SECONDS + timeout_seconds))
local services=(easyai-server ws-gateway easyai-web)
if [ "${#UPDATE_SERVICE_LIST[@]}" -gt 0 ]; then
services=("${UPDATE_SERVICE_LIST[@]}")
elif printf '%s\n' "$AVAILABLE_SERVICES" | grep -qx 'model-conversion-worker'; then
services+=(model-conversion-worker)
fi
local service container_id state running health all_ready
if ! [[ "$timeout_seconds" =~ ^[1-9][0-9]*$ ]]; then
echo "❌ UPDATE_HEALTH_TIMEOUT_SECONDS 必须是正整数,当前为: $timeout_seconds"
return 1
fi
echo "⏳ 正在等待核心服务恢复可用(最长 ${timeout_seconds} 秒)..."
while (( SECONDS < deadline )); do
all_ready=true
for service in "${services[@]}"; do
container_id="$(sudo $DOCKER_COMPOSE_CMD ps -q "$service" 2>/dev/null || true)"
if [ -z "$container_id" ]; then
all_ready=false
continue
fi
state="$(sudo docker inspect --format '{{.State.Running}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id" 2>/dev/null || true)"
running="${state%%|*}"
health="${state#*|}"
if [ "$running" != "true" ] || { [ "$health" != "none" ] && [ "$health" != "healthy" ]; }; then
all_ready=false
fi
done
if [ "$all_ready" = true ]; then
echo "✅ 核心服务已恢复可用"
return 0
fi
sleep 5
done
echo "❌ 更新后核心服务未在 ${timeout_seconds} 秒内恢复可用"
echo " 当前状态:"
sudo $DOCKER_COMPOSE_CMD ps "${services[@]}" || true
echo " 请运行 '$DOCKER_COMPOSE_CMD logs --tail=100 ${services[*]}' 查看原因"
return 1
wait_for_deployment_services "$DOCKER_COMPOSE_CMD" "${UPDATE_HEALTH_TIMEOUT_SECONDS:-600}" "${UPDATE_SERVICE_LIST[@]}"
}
# 必须在拉取/重建前确认服务名称,防止拼写错误扩大更新范围。
@@ -333,7 +282,9 @@ if [ -n "${UPDATE_SERVICES:-}" ]; then
done
fi
check_deployment_storage "$DOCKER_COMPOSE_CMD" "${UPDATE_SERVICE_LIST[@]}"
verify_application_version_images .env "$DOCKER_COMPOSE_CMD"
verify_deployment_images "$DOCKER_COMPOSE_CMD" "${UPDATE_SERVICE_LIST[@]}"
# 只检查本次会重建的 Redis;--no-deps 更新其他服务不会修改其旧数据位置。
if [ "${#UPDATE_SERVICE_LIST[@]}" -eq 0 ] || printf '%s\n' "${UPDATE_SERVICE_LIST[@]}" | grep -qx 'redis'; then
@@ -348,6 +299,7 @@ if ! sudo $DOCKER_COMPOSE_CMD pull "${UPDATE_SERVICE_LIST[@]}"; then
echo " 请检查网络、镜像仓库权限和服务器磁盘空间后重试"
exit 1
fi
verify_pulled_release "$DOCKER_COMPOSE_CMD" "${UPDATE_SERVICE_LIST[@]}"
UP_ARGUMENTS=(-d)
if [ "${#UPDATE_SERVICE_LIST[@]}" -gt 0 ]; then
UP_ARGUMENTS+=(--no-deps "${UPDATE_SERVICE_LIST[@]}")