fix(deploy): 修复新安装与旧部署升级流程 #13
+7
-4
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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:-}"
|
||||
|
||||
@@ -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 '✅ 拉取后的稳定镜像与目标发布一致'
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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']])
|
||||
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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())
|
||||
@@ -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('✅ 拉取后的稳定镜像与目标发布一致')
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
@@ -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 未被标记为更新成功" }
|
||||
}
|
||||
|
||||
@@ -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[@]}")
|
||||
|
||||
Reference in New Issue
Block a user