diff --git a/.env.sample b/.env.sample index 9d4deb9..dc23719 100644 --- a/.env.sample +++ b/.env.sample @@ -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 diff --git a/.gitignore b/.gitignore index 575ce26..ab3f9e2 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 941ab1e..0fcd5ad 100644 --- a/README.md +++ b/README.md @@ -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 配置不会被改写。 @@ -697,23 +697,17 @@ powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\start.ps1" > - **推荐**:使用 `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 + +首次域名部署直接使用统一入口,脚本会先验证容器,再配置代理和证书,并检查 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` 受控迁移入口。 ## 官方模型目录 diff --git a/docker-compose.yml b/docker-compose.yml index 05affbe..7fab979 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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:-}" diff --git a/scripts/DeploymentRuntime.ps1 b/scripts/DeploymentRuntime.ps1 new file mode 100644 index 0000000..c4b9453 --- /dev/null +++ b/scripts/DeploymentRuntime.ps1 @@ -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 '✅ 拉取后的稳定镜像与目标发布一致' +} diff --git a/scripts/Initialize-ApplicationVersions.ps1 b/scripts/Initialize-ApplicationVersions.ps1 index f8671e6..33b7fe7 100644 --- a/scripts/Initialize-ApplicationVersions.ps1 +++ b/scripts/Initialize-ApplicationVersions.ps1 @@ -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 } - } - 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='' } + $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 + } + $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)) +} diff --git a/scripts/adopt-deployment-directory.py b/scripts/adopt-deployment-directory.py new file mode 100644 index 0000000..1074313 --- /dev/null +++ b/scripts/adopt-deployment-directory.py @@ -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() diff --git a/scripts/backup-deployment-config.py b/scripts/backup-deployment-config.py new file mode 100644 index 0000000..d4b4793 --- /dev/null +++ b/scripts/backup-deployment-config.py @@ -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)) diff --git a/scripts/check-deployment-release.sh b/scripts/check-deployment-release.sh new file mode 100644 index 0000000..209f7e1 --- /dev/null +++ b/scripts/check-deployment-release.sh @@ -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 diff --git a/scripts/check-deployment-storage.py b/scripts/check-deployment-storage.py new file mode 100644 index 0000000..a94b003 --- /dev/null +++ b/scripts/check-deployment-storage.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) diff --git a/scripts/check-redis-persistence.sh b/scripts/check-redis-persistence.sh index 6993e5b..aacb2a5 100755 --- a/scripts/check-redis-persistence.sh +++ b/scripts/check-redis-persistence.sh @@ -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 diff --git a/scripts/deployment-runtime.sh b/scripts/deployment-runtime.sh new file mode 100644 index 0000000..e9955f8 --- /dev/null +++ b/scripts/deployment-runtime.sh @@ -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 +} diff --git a/scripts/init-application-version-env.sh b/scripts/init-application-version-env.sh index 6625706..ee51646 100644 --- a/scripts/init-application-version-env.sh +++ b/scripts/init-application-version-env.sh @@ -12,37 +12,23 @@ application_selected() { [ -z "${UPDATE_SERVICES:-}" ] || [[ " ${UPDATE_SERVICES} " == *" $1 "* ]] } -plan_application_versions() { - local file="$1" service key option image value suffix base old_default - APPLICATION_VERSION_WRITES=() - while read -r service key option image; do - value="${!option:-}" - if [ -n "$value" ] && ! application_selected "$service"; then - echo "❌ $option 要求在 UPDATE_SERVICES 中指定 $service;尚未修改环境或容器" >&2 - return 1 - fi - application_selected "$service" || continue - if [ -z "$value" ] && [ -n "${UPDATE_RELEASE_VERSION:-}" ]; then value="$UPDATE_RELEASE_VERSION"; fi - if [ -z "$value" ]; then value="$(read_env_value "$file" "$key")"; fi - if [ -z "$value" ]; then value="$(read_env_value "$file" VERSION)"; fi - suffix='' - if [ "$service" = easyai-web ]; then suffix='(-no-ssr|-hmg-ai|-easyai|-chinacuc)?'; fi - if ! [[ "$value" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]{8}(\.[0-9]+)?${suffix}$ ]]; then - echo "❌ $service 缺少固定版本:请指定 $option 或 UPDATE_RELEASE_VERSION,或在 .env 保留有效 $key;latest/dev 等浮动标签不能用于应用更新" >&2 - return 1 - fi - APPLICATION_VERSION_WRITES+=("$key=$value") - done <<< "$APPLICATION_VERSION_MAP" - if [ -n "${UPDATE_RELEASE_VERSION:-}" ] && application_selected easyai-server && application_selected easyai-web; then - # VERSION 仍保留兼容入口;改变它前先冻结未选中服务的旧继承值。 - old_default="$(read_env_value "$file" VERSION)" - while read -r service key option image; do - application_selected "$service" && continue - value="$(read_env_value "$file" "$key")" - if [ -z "$value" ] && [ -n "$old_default" ]; then APPLICATION_VERSION_WRITES+=("$key=$old_default"); fi - done <<< "$APPLICATION_VERSION_MAP" - APPLICATION_VERSION_WRITES+=("VERSION=$UPDATE_RELEASE_VERSION") +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" plan entry resolver_root + resolver_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + APPLICATION_VERSION_WRITES=() + 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() { diff --git a/scripts/promote-stable-release.mjs b/scripts/promote-stable-release.mjs index 1407811..7aa901f 100644 --- a/scripts/promote-stable-release.mjs +++ b/scripts/promote-stable-release.mjs @@ -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)); } diff --git a/scripts/resolve-deployment-release.py b/scripts/resolve-deployment-release.py new file mode 100644 index 0000000..f078665 --- /dev/null +++ b/scripts/resolve-deployment-release.py @@ -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()) diff --git a/scripts/test-deployment-public-url.sh b/scripts/test-deployment-public-url.sh index c5b5d2e..f287d05 100755 --- a/scripts/test-deployment-public-url.sh +++ b/scripts/test-deployment-public-url.sh @@ -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 diff --git a/scripts/test-deployment-release-resolution.py b/scripts/test-deployment-release-resolution.py new file mode 100644 index 0000000..b3e2cdd --- /dev/null +++ b/scripts/test-deployment-release-resolution.py @@ -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') diff --git a/scripts/test-deployment-release-source.py b/scripts/test-deployment-release-source.py new file mode 100644 index 0000000..4179717 --- /dev/null +++ b/scripts/test-deployment-release-source.py @@ -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() diff --git a/scripts/test-deployment-storage.py b/scripts/test-deployment-storage.py new file mode 100644 index 0000000..fe4dc1c --- /dev/null +++ b/scripts/test-deployment-storage.py @@ -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') diff --git a/scripts/test-install-failures.py b/scripts/test-install-failures.py new file mode 100644 index 0000000..9978aad --- /dev/null +++ b/scripts/test-install-failures.py @@ -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') diff --git a/scripts/test-model-conversion-update.py b/scripts/test-model-conversion-update.py index 0ea5fec..70f7de3 100644 --- a/scripts/test-model-conversion-update.py +++ b/scripts/test-model-conversion-update.py @@ -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']]) diff --git a/scripts/test-package-adoption.py b/scripts/test-package-adoption.py new file mode 100644 index 0000000..1442996 --- /dev/null +++ b/scripts/test-package-adoption.py @@ -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') diff --git a/scripts/test-proxy-entry.py b/scripts/test-proxy-entry.py new file mode 100644 index 0000000..c9ae80d --- /dev/null +++ b/scripts/test-proxy-entry.py @@ -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('redirect\\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') diff --git a/scripts/verify-deployment-images.py b/scripts/verify-deployment-images.py new file mode 100644 index 0000000..cd8e8d7 --- /dev/null +++ b/scripts/verify-deployment-images.py @@ -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()) diff --git a/scripts/verify-pulled-release.py b/scripts/verify-pulled-release.py new file mode 100644 index 0000000..eb4b7b3 --- /dev/null +++ b/scripts/verify-pulled-release.py @@ -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('✅ 拉取后的稳定镜像与目标发布一致') diff --git a/start.ps1 b/start.ps1 index acad769..07b76b0 100644 --- a/start.ps1 +++ b/start.ps1 @@ -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" } diff --git a/start.sh b/start.sh index 9e2cf84..220b969 100755 --- a/start.sh +++ b/start.sh @@ -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 "" diff --git a/system-update/deployment-components.json b/system-update/deployment-components.json new file mode 100644 index 0000000..8fb99c2 --- /dev/null +++ b/system-update/deployment-components.json @@ -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" + } + ] +} diff --git a/update.ps1 b/update.ps1 index 9d8c7ac..ed209ea 100644 --- a/update.ps1 +++ b/update.ps1 @@ -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 未被标记为更新成功" } } diff --git a/update.sh b/update.sh index 630a058..0177b4d 100755 --- a/update.sh +++ b/update.sh @@ -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,11 +121,12 @@ 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" - exit 1 + 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)" @@ -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[@]}")