From 195e2e5a98adfa57a590055fae2b478c30ab07d9 Mon Sep 17 00:00:00 2001 From: wangbo Date: Sun, 27 Sep 2026 11:12:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E5=A2=9E=E5=8A=A0=E5=8F=97?= =?UTF-8?q?=E6=8E=A7=E6=9B=B4=E6=96=B0=E4=B8=8E=E7=8E=AF=E5=A2=83=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 17 ++ README.md | 25 +++ docker-compose.yml | 74 ++++++++- scripts/Apply-UpdateEnvMigrations.ps1 | 111 +++++++++++++ scripts/Initialize-SecurityEnv.ps1 | 3 +- scripts/apply-update-env-migrations.sh | 151 ++++++++++++++++++ scripts/init-security-env.sh | 1 + scripts/test-deployment-public-url.sh | 58 +++++++ scripts/test-update-env-migrations.sh | 57 +++++++ .../010-managed-system-update.env | 11 ++ update.ps1 | 95 ++++++++++- update.sh | 81 +++++++++- 12 files changed, 667 insertions(+), 17 deletions(-) create mode 100644 scripts/Apply-UpdateEnvMigrations.ps1 create mode 100755 scripts/apply-update-env-migrations.sh create mode 100755 scripts/test-update-env-migrations.sh create mode 100644 scripts/update-env.d/010-managed-system-update.env diff --git a/.env.sample b/.env.sample index 63c2d37..e654483 100644 --- a/.env.sample +++ b/.env.sample @@ -253,6 +253,23 @@ STATIC_CDN_FORCE_UPLOAD=false # ========== 15. 版本 ========== VERSION=latest +# ========== 15.1 在线更新 ========== +# 新安装默认使用可查询任务模式;存量环境执行 update.sh 后会自动补齐缺失配置。 +# 若已显式配置 SYSTEM_UPDATE_MODE=legacy_watchtower,迁移脚本会保留该选择。 +SYSTEM_UPDATE_MODE=managed +COMPOSE_PROFILES=managed-update +WATCHTOWER_CORE_UPDATE_ENABLED=false +# 更新器由独立于任务执行进程的 Watchtower 每 24 小时检查一次,避免自替换中断任务。 +WATCHTOWER_UPDATE_TARGET=easyai-updater +SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS=86400 +WATCHTOWER_CLEANUP=true +# 核心服务替换后最多等待 5 分钟恢复可用。 +UPDATE_HEALTH_TIMEOUT_MS=300000 +# 私有镜像仓库登录信息;执行 docker login 后通常位于此处。 +DOCKER_CONFIG_FILE=/root/.docker/config.json +SYSTEM_UPDATE_INTERNAL_TOKEN= +EASYAI_DEPLOYMENT_SCHEMA_VERSION=2 + # ========== 16. OSS 配置(可选,PDF 解析图片上传) ========== # 可填写 .env.tools 或在此覆盖 # OSS_ENDPOINT= diff --git a/README.md b/README.md index ea23830..cd5276b 100644 --- a/README.md +++ b/README.md @@ -172,9 +172,33 @@ mv easyai easyai2 update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能: - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) +- 每次默认执行 `scripts/update-env.d/` 中的版本化环境配置迁移,只补缺失/空值和必要列表项,不覆盖已有非空配置 +- 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效 - 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL` - 兼容 `docker compose` 和 `docker-compose` 两种命令格式 - 自动拉取最新镜像并重启服务 +- 等待主服务、Web 和 WebSocket 网关恢复可用后才报告成功;拉取失败、启动失败或恢复超时会明确报告失败 + +新安装默认启用可查询进度的在线更新模式。管理员在页面发起更新后,即使主服务重启导致原请求断开,页面也会根据任务编号继续查询最终结果。存量部署执行新版 `update.sh` / `update.ps1` 时会自动补齐新模式所需的缺失配置;已经显式设置 `SYSTEM_UPDATE_MODE=legacy_watchtower` 的环境仍保留原模式。新模式只管理 `easyai-server`、`easyai-web` 和 `ws-gateway`,数据库、Redis、RabbitMQ 等基础服务仍按原部署流程维护。 + +更新执行器 `easyai-updater` 由 Watchtower 单独守护,默认每 24 小时检查并更新自身。它的任务状态保存在宿主机 `data/update-state` 中,自身更新或意外重启后会继续恢复未完成任务;Watchtower 不再接管三个核心服务,因此页面任务结果不会与后台静默替换相互冲突。 + +存量部署无需手工编辑 `.env`。更新脚本会从 `scripts/update-env.d/010-managed-system-update.env` 应用以下缺省值: + +```dotenv +SYSTEM_UPDATE_MODE=managed +COMPOSE_PROFILES=managed-update +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 +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` 以及三个核心服务均处于可用状态。 + +以后新增必需环境变量时,在 `scripts/update-env.d/` 增加新的编号 `.env` 文件即可,不需要继续把写入逻辑堆进 `update.sh`。迁移文件支持 `KEY=value`(仅在缺失或空值时写入)、`KEY+=item`(确保逗号列表包含该项),以及 `@skip-if KEY=value`(明确保留某种旧模式);所有迁移每次都会执行并保持幂等。迁移失败会在拉取镜像前终止,避免使用半完成配置启动服务。 ### 使用步骤 1. [首次执行,后续无需重复执行]添加执行权限,命令: @@ -213,6 +237,7 @@ UPDATE_SKIP_REPO_UPDATE=true ./update.sh - 脚本会执行 `git pull` 拉取整个仓库最新代码 - 拉取后会检查并补齐缺失的 .env、.env.tools、.env.ASG、.env.AMS(不会覆盖已有文件) - 最后执行 `docker compose pull` 和 `docker compose up -d` 拉取镜像并重启服务 +- 默认最多等待 300 秒确认核心服务恢复可用;可通过 `UPDATE_HEALTH_TIMEOUT_SECONDS` 调整 ### Windows 用户(update.ps1) Windows 下使用 `update.ps1`,功能与 Linux 版一致: diff --git a/docker-compose.yml b/docker-compose.yml index 6976293..c896cdf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION} #端口冲突时,只需要修改前面的端口比如修改为,3011:3010 labels: - - "com.centurylinklabs.watchtower.enable=true" + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" ports: - "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${WEB_PORT}:3010" # read_only: true @@ -41,6 +41,12 @@ services: options: max-size: "100m" max-file: "10" + 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 # 资源限制,防止内存溢出 deploy: resources: @@ -53,7 +59,7 @@ services: # 阿里云镜像地址 image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云 labels: - - "com.centurylinklabs.watchtower.enable=true" + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" ports: - "${SERVER_HTTP_BIND_IP:-127.0.0.1}:${SERVER_HTTP_PORT}:3001" # Linux Nginx 部署默认仅回环;Windows/LAN 直连由启动脚本显式覆盖 # read_only: true @@ -133,11 +139,16 @@ services: # easyai-server 发布事件到 ws-gateway(默认走容器内网服务名) - WS_GATEWAY_TCP_HOST=${WS_GATEWAY_TCP_HOST:-ws-gateway} - WS_GATEWAY_TCP_PORT=${WS_GATEWAY_TCP_PORT:-4002} + # 在线更新:未配置时保持原 Watchtower 兼容模式。 + - SYSTEM_UPDATE_MODE=${SYSTEM_UPDATE_MODE:-legacy_watchtower} + - SYSTEM_UPDATE_SERVICE_URL=${SYSTEM_UPDATE_SERVICE_URL:-http://easyai-updater:3015} + - SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN} + - EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1} # 日志大小设置,避免日志文件过大 env_file: - .env healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3001/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""] + test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3001/health/update-ready', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""] interval: 30s timeout: 10s retries: 3 @@ -152,7 +163,7 @@ services: # 阿里云镜像地址 image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:latest # 阿里云 labels: - - "com.centurylinklabs.watchtower.enable=true" + - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}" ports: - "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${CONFIG_WS_PORT:-3002}:3002" # Linux IP/域名模式默认仅供同机 Nginx 访问 read_only: true @@ -187,6 +198,8 @@ services: - GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001} - GATEWAY_INBOUND_TCP_PATTERN=${GATEWAY_INBOUND_TCP_PATTERN:-gateway.inbound.command} - GATEWAY_INBOUND_TIMEOUT_MS=${GATEWAY_INBOUND_TIMEOUT_MS:-5000} + - WS_GATEWAY_HEALTH_PORT=3012 + - EASYAI_BUILD_ID=${EASYAI_BUILD_ID:-unknown} # 日志大小设置,避免日志文件过大 env_file: - .env @@ -195,6 +208,12 @@ services: options: max-size: "100m" max-file: "10" + 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 mongo: # 镜像见 .env 中 MONGO_IMAGE;4.4 备选地址与用途见 .env.sample 注释 image: ${MONGO_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest} @@ -278,7 +297,12 @@ services: image: registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest volumes: - /var/run/docker.sock:/var/run/docker.sock - command: --debug + - ${DOCKER_CONFIG_FILE:-/root/.docker/config.json}:/config.json:ro + # 新安装只负责 easyai-updater 自身;存量环境未配置目标时保持原来的全容器扫描方式。 + command: --debug ${WATCHTOWER_UPDATE_TARGET:-} + environment: + - WATCHTOWER_POLL_INTERVAL=${SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS:-86400} + - WATCHTOWER_CLEANUP=${WATCHTOWER_CLEANUP:-false} restart: always labels: - "com.centurylinklabs.watchtower.enable=false" @@ -294,6 +318,46 @@ services: options: max-size: "100m" max-file: "10" + easyai-updater: + image: registry.cn-shanghai.aliyuncs.com/easyaigc/easyai-updater:latest + container_name: easyai-updater + profiles: + - managed-update + labels: + - "com.centurylinklabs.watchtower.enable=true" + restart: unless-stopped + read_only: true + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + environment: + - PORT=3015 + - SYSTEM_UPDATE_INTERNAL_TOKEN=${SYSTEM_UPDATE_INTERNAL_TOKEN} + - EASYAI_DEPLOYMENT_SCHEMA_VERSION=${EASYAI_DEPLOYMENT_SCHEMA_VERSION:-1} + - UPDATE_HEALTH_TIMEOUT_MS=${UPDATE_HEALTH_TIMEOUT_MS:-300000} + - DOCKER_CONFIG_FILE=/run/secrets/docker-config.json + - UPDATE_STATE_DIR=/var/lib/easyai-updater + - UPDATE_MANIFEST_FILE=/var/lib/easyai-updater/release-manifest.json + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${DOCKER_CONFIG_FILE:-/root/.docker/config.json}:/run/secrets/docker-config.json:ro + - ./data/update-state:/var/lib/easyai-updater + tmpfs: + - /tmp:mode=1777 + networks: + - easyai + healthcheck: + test: ["CMD-SHELL", "node -e \"require('http').get('http://127.0.0.1:3015/health', (res) => { res.resume(); process.exit(res.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))\""] + interval: 10s + timeout: 5s + retries: 6 + start_period: 10s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "3" video-edit: image: registry.cn-shanghai.aliyuncs.com/easyaigc/videoedit:latest container_name: video-edit diff --git a/scripts/Apply-UpdateEnvMigrations.ps1 b/scripts/Apply-UpdateEnvMigrations.ps1 new file mode 100644 index 0000000..d4ef377 --- /dev/null +++ b/scripts/Apply-UpdateEnvMigrations.ps1 @@ -0,0 +1,111 @@ +param( + [Parameter(Mandatory = $true)][string]$EnvPath, + [Parameter(Mandatory = $true)][string]$ProjectRoot +) + +$ErrorActionPreference = "Stop" +$migrationsDir = if ([string]::IsNullOrWhiteSpace($env:UPDATE_ENV_MIGRATIONS_DIR)) { + Join-Path $ProjectRoot "scripts\update-env.d" +} else { + $env:UPDATE_ENV_MIGRATIONS_DIR +} + +if (-not (Test-Path $EnvPath -PathType Leaf)) { + throw "环境配置文件不存在: $EnvPath" +} +if (-not (Test-Path $migrationsDir -PathType Container)) { + Write-Host "ℹ️ 未发现环境配置迁移目录,跳过" + return +} + +function Get-EnvValue { + param([string]$Path, [string]$Key) + foreach ($line in [System.IO.File]::ReadAllLines($Path)) { + if ($line.StartsWith("$Key=")) { + return $line.Substring($Key.Length + 1).Trim().Trim('"').Trim("'") + } + } + return "" +} + +function Set-EnvValue { + param([string]$Path, [string]$Key, [string]$Value) + $output = New-Object System.Collections.Generic.List[string] + $replaced = $false + foreach ($line in [System.IO.File]::ReadAllLines($Path)) { + if ($line.StartsWith("$Key=")) { + if (-not $replaced) { + $output.Add("$Key=$Value") + $replaced = $true + } + } else { + $output.Add($line) + } + } + if (-not $replaced) { $output.Add("$Key=$Value") } + [System.IO.File]::WriteAllLines($Path, $output, (New-Object System.Text.UTF8Encoding($false))) +} + +function Set-EnvDefault { + param([string]$Key, [string]$Value) + if (-not [string]::IsNullOrWhiteSpace((Get-EnvValue -Path $EnvPath -Key $Key))) { return } + Set-EnvValue -Path $EnvPath -Key $Key -Value $Value + Write-Host " ✓ 已补充环境配置: $Key" +} + +function Add-EnvListItem { + param([string]$Key, [string]$Item) + $current = Get-EnvValue -Path $EnvPath -Key $Key + $items = @($current.Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + if ($items -contains $Item) { return } + $next = if ($items.Count -eq 0) { $Item } else { (@($items) + $Item) -join ',' } + Set-EnvValue -Path $EnvPath -Key $Key -Value $next + Write-Host " ✓ 已补充环境配置项: $Key" +} + +$manifests = @(Get-ChildItem -Path $migrationsDir -Filter "*.env" -File | Sort-Object Name) +if ($manifests.Count -eq 0) { + Write-Host "ℹ️ 未发现环境配置迁移文件,跳过" + return +} + +foreach ($manifest in $manifests) { + $lineNumber = 0 + foreach ($rawLine in [System.IO.File]::ReadAllLines($manifest.FullName)) { + $lineNumber++ + $line = $rawLine.TrimEnd("`r") + if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { continue } + if ($line -match '^@skip-if\s+[A-Z][A-Z0-9_]*=.+$') { continue } + if ($line -notmatch '^[A-Z][A-Z0-9_]*(\+)?=.+$') { + throw "无效的环境配置迁移: $($manifest.FullName):$lineNumber" + } + } +} + +foreach ($manifest in $manifests) { + $skipManifest = $false + foreach ($rawLine in [System.IO.File]::ReadAllLines($manifest.FullName)) { + $line = $rawLine.TrimEnd("`r") + if ($line -match '^@skip-if\s+([A-Z][A-Z0-9_]*)=(.+)$') { + if ((Get-EnvValue -Path $EnvPath -Key $Matches[1]) -eq $Matches[2]) { + Write-Host "ℹ️ 保留现有更新模式,跳过环境配置迁移: $($manifest.Name)" + $skipManifest = $true + break + } + } + } + if ($skipManifest) { continue } + Write-Host "📝 应用环境配置迁移: $($manifest.Name)" + foreach ($rawLine in [System.IO.File]::ReadAllLines($manifest.FullName)) { + $line = $rawLine.TrimEnd("`r") + if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith('#')) { continue } + if ($line.StartsWith('@skip-if')) { continue } + if ($line.Contains('+=')) { + $parts = $line.Split(@('+='), 2, [System.StringSplitOptions]::None) + Add-EnvListItem -Key $parts[0] -Item $parts[1] + } else { + $parts = $line.Split(@('='), 2, [System.StringSplitOptions]::None) + Set-EnvDefault -Key $parts[0] -Value $parts[1] + } + } +} diff --git a/scripts/Initialize-SecurityEnv.ps1 b/scripts/Initialize-SecurityEnv.ps1 index 528ac17..3b47093 100644 --- a/scripts/Initialize-SecurityEnv.ps1 +++ b/scripts/Initialize-SecurityEnv.ps1 @@ -57,7 +57,8 @@ function Initialize-SecurityEnv { if ($null -eq $content) { $content = "" } $definitions = @( @{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 }, - @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 } + @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 }, + @{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 } ) if ($Mode -eq "new") { $definitions += @( diff --git a/scripts/apply-update-env-migrations.sh b/scripts/apply-update-env-migrations.sh new file mode 100755 index 0000000..8f4abc3 --- /dev/null +++ b/scripts/apply-update-env-migrations.sh @@ -0,0 +1,151 @@ +#!/bin/bash + +set -euo pipefail + +ENV_FILE="${1:-.env}" +PROJECT_ROOT="${2:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +MIGRATIONS_DIR="${UPDATE_ENV_MIGRATIONS_DIR:-$PROJECT_ROOT/scripts/update-env.d}" + +if [ ! -f "$ENV_FILE" ]; then + echo "❌ 环境配置文件不存在: $ENV_FILE" >&2 + exit 1 +fi + +if [ ! -d "$MIGRATIONS_DIR" ]; then + echo "ℹ️ 未发现环境配置迁移目录,跳过" + exit 0 +fi + +read_env_value() { + local file="$1" + local key="$2" + local value + value="$(awk -v key="$key" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$file")" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} + +write_env_value() { + local file="$1" + local key="$2" + local value="$3" + local temporary + temporary="$(mktemp "${file}.tmp.XXXXXX")" + awk -v key="$key" -v value="$value" ' + BEGIN { replaced = 0 } + index($0, key "=") == 1 { + if (!replaced) { + print key "=" value + replaced = 1 + } + next + } + { print } + END { if (!replaced) print key "=" value } + ' "$file" > "$temporary" + chmod 600 "$temporary" + mv "$temporary" "$file" +} + +apply_default() { + local key="$1" + local value="$2" + if [ -n "$(read_env_value "$ENV_FILE" "$key")" ]; then + return 0 + fi + write_env_value "$ENV_FILE" "$key" "$value" + echo " ✓ 已补充环境配置: $key" +} + +ensure_list_item() { + local key="$1" + local item="$2" + local current normalized + current="$(read_env_value "$ENV_FILE" "$key")" + normalized="$(printf '%s' "$current" | tr -d '[:space:]')" + case ",${normalized}," in + *",${item},"*) return 0 ;; + esac + if [ -n "$current" ]; then + current="${current},${item}" + else + current="$item" + fi + write_env_value "$ENV_FILE" "$key" "$current" + echo " ✓ 已补充环境配置项: $key" +} + +validate_manifest() { + local manifest="$1" + local line line_number=0 + while IFS= read -r line || [ -n "$line" ]; do + line_number=$((line_number + 1)) + line="${line%$'\r'}" + case "$line" in + ""|'#'*) continue ;; + esac + if [[ "$line" =~ ^@skip-if[[:space:]]+[A-Z][A-Z0-9_]*=.+$ ]]; then + continue + fi + if ! [[ "$line" =~ ^[A-Z][A-Z0-9_]*(\+)?=.+$ ]]; then + echo "❌ 无效的环境配置迁移: ${manifest}:${line_number}" >&2 + return 1 + fi + done < "$manifest" +} + +apply_manifest() { + local manifest="$1" + local line key value current + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + if [[ "$line" =~ ^@skip-if[[:space:]]+([A-Z][A-Z0-9_]*)=(.+)$ ]]; then + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + current="$(read_env_value "$ENV_FILE" "$key")" + if [ "$current" = "$value" ]; then + echo "ℹ️ 保留现有更新模式,跳过环境配置迁移: $(basename "$manifest")" + return 0 + fi + fi + done < "$manifest" + echo "📝 应用环境配置迁移: $(basename "$manifest")" + while IFS= read -r line || [ -n "$line" ]; do + line="${line%$'\r'}" + case "$line" in + ""|'#'*) continue ;; + esac + if [[ "$line" == @skip-if* ]]; then + continue + fi + if [[ "$line" == *'+='* ]]; then + key="${line%%+=*}" + value="${line#*+=}" + ensure_list_item "$key" "$value" + else + key="${line%%=*}" + value="${line#*=}" + apply_default "$key" "$value" + fi + done < "$manifest" +} + +shopt -s nullglob +manifests=("$MIGRATIONS_DIR"/*.env) +if [ "${#manifests[@]}" -eq 0 ]; then + echo "ℹ️ 未发现环境配置迁移文件,跳过" + exit 0 +fi + +for manifest in "${manifests[@]}"; do + validate_manifest "$manifest" +done +for manifest in "${manifests[@]}"; do + apply_manifest "$manifest" +done + +chmod 600 "$ENV_FILE" diff --git a/scripts/init-security-env.sh b/scripts/init-security-env.sh index e6b27db..ebb6870 100644 --- a/scripts/init-security-env.sh +++ b/scripts/init-security-env.sh @@ -129,6 +129,7 @@ init_security_env() { ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret" ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET" + ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN" ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer" if [ "$mode" = "new" ]; then ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" diff --git a/scripts/test-deployment-public-url.sh b/scripts/test-deployment-public-url.sh index 709461a..cb9a301 100755 --- a/scripts/test-deployment-public-url.sh +++ b/scripts/test-deployment-public-url.sh @@ -144,10 +144,66 @@ watchtower = services["watchtower"] command = " ".join(watchtower.get("command") or []) assert "http-api-update" not in command, command assert "WATCHTOWER_HTTP_API_TOKEN" not in (watchtower.get("environment") or {}), watchtower.get("environment") +watchtower_mounts = {(item.get("source"), item.get("target")) for item in watchtower.get("volumes") or []} +assert any(target == "/config.json" for _, target in watchtower_mounts), watchtower_mounts assert services["dozzle"]["environment"]["DOZZLE_ENABLE_DOWNLOAD"] == "false", services["dozzle"]["environment"] ' } +assert_managed_update_contract() { + docker compose config --format json | python3 -c ' +import json, sys +services = json.load(sys.stdin)["services"] +updater = services["easyai-updater"] +server = services["easyai-server"] +watchtower = services["watchtower"] + +assert updater.get("ports") in (None, []), updater.get("ports") +assert updater.get("read_only") is True, updater.get("read_only") +assert set(updater.get("cap_drop") or []) == {"ALL"}, updater.get("cap_drop") +assert "no-new-privileges:true" in (updater.get("security_opt") or []), updater.get("security_opt") +mounts = {(item.get("source"), item.get("target")) for item in updater.get("volumes") or []} +assert ("/var/run/docker.sock", "/var/run/docker.sock") in mounts, mounts +assert any(target == "/var/lib/easyai-updater" for _, target in mounts), mounts +assert any(target == "/run/secrets/docker-config.json" for _, target in mounts), mounts + +server_env = server.get("environment") or {} +updater_env = updater.get("environment") or {} +token = str(server_env.get("SYSTEM_UPDATE_INTERNAL_TOKEN") or "") +assert len(token.encode()) >= 32, "managed updater token is missing or too short" +assert token == str(updater_env.get("SYSTEM_UPDATE_INTERNAL_TOKEN") or ""), "server/updater tokens differ" +assert server_env.get("SYSTEM_UPDATE_MODE") == "managed", server_env.get("SYSTEM_UPDATE_MODE") +assert str(server_env.get("EASYAI_DEPLOYMENT_SCHEMA_VERSION")) == "2" +assert str(updater_env.get("UPDATE_HEALTH_TIMEOUT_MS")) == "300000" +assert updater_env.get("DOCKER_CONFIG_FILE") == "/run/secrets/docker-config.json" +assert updater.get("labels", {}).get("com.centurylinklabs.watchtower.enable") == "true" +assert watchtower.get("environment", {}).get("WATCHTOWER_POLL_INTERVAL") == "86400" +assert watchtower.get("environment", {}).get("WATCHTOWER_CLEANUP") == "true" +assert "easyai-updater" in (watchtower.get("command") or []), watchtower.get("command") + +for service in ("easyai-server", "ws-gateway", "easyai-web"): + labels = services[service].get("labels") or {} + assert labels.get("com.centurylinklabs.watchtower.enable") == "false", (service, labels) +' +} + +assert_legacy_update_contract() { + local legacy_env="$TMP_DIR/.env.legacy" + grep -Ev '^(SYSTEM_UPDATE_MODE|COMPOSE_PROFILES|WATCHTOWER_CORE_UPDATE_ENABLED|WATCHTOWER_UPDATE_TARGET|SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS|WATCHTOWER_CLEANUP|UPDATE_HEALTH_TIMEOUT_MS|DOCKER_CONFIG_FILE|SYSTEM_UPDATE_INTERNAL_TOKEN|EASYAI_DEPLOYMENT_SCHEMA_VERSION)=' .env > "$legacy_env" + docker compose --env-file "$legacy_env" config --format json 2>/dev/null | python3 -c ' +import json, sys +services = json.load(sys.stdin)["services"] +assert "easyai-updater" not in services, sorted(services) +server_env = services["easyai-server"].get("environment") or {} +assert server_env.get("SYSTEM_UPDATE_MODE") == "legacy_watchtower", server_env.get("SYSTEM_UPDATE_MODE") +assert str(server_env.get("EASYAI_DEPLOYMENT_SCHEMA_VERSION")) == "1" +assert "easyai-updater" not in (services["watchtower"].get("command") or []), services["watchtower"].get("command") +for service in ("easyai-server", "ws-gateway", "easyai-web"): + labels = services[service].get("labels") or {} + assert labels.get("com.centurylinklabs.watchtower.enable") == "true", (service, labels) +' +} + sed -i.bak 's/^SERVER_HTTP_PORT=.*/SERVER_HTTP_PORT=4100/' .env.sample rm -f .env.sample.bak DEPLOY_NON_INTERACTIVE=1 \ @@ -202,6 +258,8 @@ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; assert_canvas_ws_auth_config assert_compose_security assert_redis_startup_contract + assert_managed_update_contract + assert_legacy_update_contract fi reset_case diff --git a/scripts/test-update-env-migrations.sh b/scripts/test-update-env-migrations.sh new file mode 100755 index 0000000..ee50d9f --- /dev/null +++ b/scripts/test-update-env-migrations.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +cleanup() { + find "$TMP_DIR" -type f -delete + rmdir "$TMP_DIR" +} +trap cleanup EXIT + +LEGACY_ENV_FILE="$TMP_DIR/legacy.env" +cat > "$LEGACY_ENV_FILE" <<'EOF' +EXISTING_VALUE=keep-me +SYSTEM_UPDATE_MODE=legacy_watchtower +COMPOSE_PROFILES=customer-profile +WATCHTOWER_CLEANUP= +EOF + +bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$LEGACY_ENV_FILE" "$PROJECT_ROOT" >/dev/null + +grep -qx 'EXISTING_VALUE=keep-me' "$LEGACY_ENV_FILE" +grep -qx 'SYSTEM_UPDATE_MODE=legacy_watchtower' "$LEGACY_ENV_FILE" +grep -qx 'COMPOSE_PROFILES=customer-profile' "$LEGACY_ENV_FILE" +grep -qx 'WATCHTOWER_CLEANUP=' "$LEGACY_ENV_FILE" +[ "$(wc -l < "$LEGACY_ENV_FILE" | tr -d ' ')" = "4" ] + +ENV_FILE="$TMP_DIR/.env" +cat > "$ENV_FILE" <<'EOF' +EXISTING_VALUE=keep-me +COMPOSE_PROFILES=customer-profile +WATCHTOWER_CLEANUP= +EOF + +bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null + +read_value() { + awk -v key="$1" 'index($0, key "=") == 1 { print substr($0, length(key) + 2); exit }' "$ENV_FILE" +} + +[ "$(read_value EXISTING_VALUE)" = "keep-me" ] +[ "$(read_value SYSTEM_UPDATE_MODE)" = "managed" ] +[ "$(read_value COMPOSE_PROFILES)" = "customer-profile,managed-update" ] +[ "$(read_value WATCHTOWER_CLEANUP)" = "true" ] +[ "$(read_value WATCHTOWER_CORE_UPDATE_ENABLED)" = "false" ] +[ "$(read_value WATCHTOWER_UPDATE_TARGET)" = "easyai-updater" ] +[ "$(read_value SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS)" = "86400" ] +[ "$(read_value UPDATE_HEALTH_TIMEOUT_MS)" = "300000" ] +[ "$(read_value DOCKER_CONFIG_FILE)" = "/root/.docker/config.json" ] +[ "$(read_value EASYAI_DEPLOYMENT_SCHEMA_VERSION)" = "2" ] + +cp "$ENV_FILE" "$TMP_DIR/first-run.env" +bash "$PROJECT_ROOT/scripts/apply-update-env-migrations.sh" "$ENV_FILE" "$PROJECT_ROOT" >/dev/null +cmp "$TMP_DIR/first-run.env" "$ENV_FILE" + +echo "Update environment migration tests passed" diff --git a/scripts/update-env.d/010-managed-system-update.env b/scripts/update-env.d/010-managed-system-update.env new file mode 100644 index 0000000..1d5d95a --- /dev/null +++ b/scripts/update-env.d/010-managed-system-update.env @@ -0,0 +1,11 @@ +# 可查询进度的在线更新模式。已有非空配置保持不变。 +@skip-if SYSTEM_UPDATE_MODE=legacy_watchtower +SYSTEM_UPDATE_MODE=managed +COMPOSE_PROFILES+=managed-update +WATCHTOWER_CORE_UPDATE_ENABLED=false +WATCHTOWER_UPDATE_TARGET=easyai-updater +SYSTEM_UPDATER_UPDATE_INTERVAL_SECONDS=86400 +WATCHTOWER_CLEANUP=true +UPDATE_HEALTH_TIMEOUT_MS=300000 +DOCKER_CONFIG_FILE=/root/.docker/config.json +EASYAI_DEPLOYMENT_SCHEMA_VERSION=2 diff --git a/update.ps1 b/update.ps1 index 5e38e80..4914c2e 100644 --- a/update.ps1 +++ b/update.ps1 @@ -58,6 +58,10 @@ if ($args -contains "-h" -or $args -contains "--help") { Write-Host " false 等同 UPDATE_MODE=full" Write-Host " UPDATE_NO_WAIT=1" Write-Host " 结束或失败时不等待按 Enter" + Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" + Write-Host " 等待核心服务恢复可用的最长时间,默认 300 秒" + Write-Host "" + Write-Host "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。" exit 0 } @@ -136,9 +140,10 @@ if (-not $skipRepoUpdate) { Write-Err "当前目录不是 Git 仓库,请使用 git clone 克隆项目后使用 update.ps1" } - Write-Step "📥 正在执行 git pull..." + $beforeRevision = (git rev-parse HEAD).Trim() + Write-Step "📥 正在执行 git pull --ff-only..." try { - $output = git pull 2>&1 + $output = git pull --ff-only 2>&1 if ($LASTEXITCODE -ne 0) { throw "git pull 返回 $LASTEXITCODE" } Write-Ok "仓库已更新到最新版本" } catch { @@ -165,6 +170,16 @@ if (-not $skipRepoUpdate) { Write-Ok ".env.AMS" } + $afterRevision = (git rev-parse HEAD).Trim() + if ($beforeRevision -ne $afterRevision -and $env:EASYAI_UPDATE_REEXECUTED -ne "1") { + Write-Step "🔄 检测到部署仓库已更新,切换到新版更新脚本继续执行..." + $env:EASYAI_UPDATE_REEXECUTED = "1" + $env:UPDATE_SKIP_REPO_UPDATE = "true" + $hostExecutable = (Get-Process -Id $PID).Path + & $hostExecutable -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath + exit $LASTEXITCODE + } + Write-Host "" } else { Write-Host "" @@ -174,6 +189,12 @@ if (-not $skipRepoUpdate) { # 空值、历史默认 JWT 密钥和缺失的 WS ticket 密钥只初始化一次并写回 .env;已有强密钥保持不变。 if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps1 完成初始化" } + +# 每次更新都执行版本化、幂等的环境配置迁移;已有非空自定义值保持不变。 +& (Join-Path $scriptDir "scripts\Apply-UpdateEnvMigrations.ps1") ` + -EnvPath (Join-Path $scriptDir ".env") ` + -ProjectRoot $scriptDir + . (Join-Path $scriptDir "scripts\Initialize-ServerHttpBindIp.ps1") Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env") @@ -243,21 +264,83 @@ Assert-RedisPersistence -ProjectRoot $scriptDir $hasComposeV2 = $false try { $null = docker compose version 2>&1; $hasComposeV2 = $true } catch { } +function Invoke-Compose { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$ComposeArgs) + if ($hasComposeV2) { + & docker compose @ComposeArgs + } else { + & docker-compose @ComposeArgs + } + return $LASTEXITCODE +} + +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 = @("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 easyai-server ws-gateway easyai-web + } else { + docker-compose ps easyai-server ws-gateway easyai-web + } + Write-Err "请检查核心服务日志,确认失败原因后重试" +} + # ==================== 更新并启动 ==================== Write-Host "" Write-Step "🚀 重新启动 EasyAI..." +Write-Step "📦 当前磁盘空间:" +Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Root -eq ([System.IO.Path]::GetPathRoot($scriptDir)) } | Format-Table Name, Used, Free -AutoSize if ($hasComposeV2) { docker compose pull - if ($LASTEXITCODE -ne 0) { Write-Err "docker compose pull 失败" } + if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" } docker compose up -d - if ($LASTEXITCODE -ne 0) { Write-Err "docker compose up 失败" } + if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" } } else { docker-compose pull - if ($LASTEXITCODE -ne 0) { Write-Err "docker-compose pull 失败" } + if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" } docker-compose up -d - if ($LASTEXITCODE -ne 0) { Write-Err "docker-compose up 失败" } + if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" } } +Wait-CoreServices + Write-Host "" Write-Host "================================" Write-Host " 更新完成" diff --git a/update.sh b/update.sh index 714e3fb..1abb7f1 100755 --- a/update.sh +++ b/update.sh @@ -15,6 +15,10 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then echo " UPDATE_SKIP_REPO_UPDATE=true|false" echo " true 等同 UPDATE_MODE=image" echo " false 等同 UPDATE_MODE=full" + echo " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数" + echo " 等待核心服务恢复可用的最长时间,默认 300 秒" + echo "" + echo "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。" exit 0 fi @@ -100,8 +104,9 @@ if [ "$SKIP_REPO_UPDATE" = false ]; then exit 1 fi - echo "📥 正在执行 git pull..." - if git pull; then + before_revision="$(git rev-parse HEAD)" + echo "📥 正在执行 git pull --ff-only..." + if git pull --ff-only; then echo "✅ 仓库已更新到最新版本" else echo "❌ git pull 失败,请检查网络或远程仓库配置" @@ -116,6 +121,12 @@ if [ "$SKIP_REPO_UPDATE" = false ]; then [ ! -f .env.ASG ] && [ -f .env.ASG.sample ] && cp .env.ASG.sample .env.ASG && echo " ✓ .env.ASG" [ ! -f .env.AMS ] && [ -f .env.AMS.sample ] && cp .env.AMS.sample .env.AMS && echo " ✓ .env.AMS" + after_revision="$(git rev-parse HEAD)" + if [ "$before_revision" != "$after_revision" ] && [ "${EASYAI_UPDATE_REEXECUTED:-0}" != "1" ]; then + echo "🔄 检测到部署仓库已更新,切换到新版更新脚本继续执行..." + exec env EASYAI_UPDATE_REEXECUTED=1 UPDATE_SKIP_REPO_UPDATE=true "$SCRIPT_DIR/update.sh" + fi + echo "" else echo "⏭️ 跳过仓库更新,仅更新镜像并重启" @@ -129,6 +140,10 @@ if [ ! -f .env ]; then exit 1 fi +# 每次更新都执行版本化、幂等的环境配置迁移。迁移只补缺失/空值或必要的列表项, +# 不覆盖用户已经配置的非空值。后续新增必需配置时只需增加编号迁移文件。 +bash ./scripts/apply-update-env-migrations.sh .env "$SCRIPT_DIR" + # 老部署缺少绑定配置时,根据现有公开 API 地址迁移:域名/本机保持回环,IP 直连保持可用。 # shellcheck source=scripts/init-server-http-bind-ip.sh . ./scripts/init-server-http-bind-ip.sh @@ -162,7 +177,7 @@ else sudo apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg lsb-release echo "🔑 添加 Docker GPG 密钥..." - curl -fsSL https://mirrors.nwafu.edu.cn生成注释ker-ce/linux/ubuntu/gpg | sudo apt-key add - + curl -fsSL https://mirrors.nwafu.edu.cn/docker-ce/linux/ubuntu/gpg | sudo apt-key add - echo "🌍 添加 Docker 源..." sudo add-apt-repository -y "deb [arch=amd64] https://mirrors.nwafu.edu.cn生成注释ker-ce/linux/ubuntu/ $(lsb_release -cs) stable" @@ -203,9 +218,65 @@ $DOCKER_COMPOSE_CMD version echo "🎉 Docker 和 Docker Compose 已就绪!" +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) + 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 easyai-server ws-gateway easyai-web || true + echo " 请运行 '$DOCKER_COMPOSE_CMD logs --tail=100 easyai-server ws-gateway easyai-web' 查看原因" + return 1 +} + # 旧 Redis 若仍把 AOF/RDB 留在容器写层,直接 up 会丢失数据。 sudo ./scripts/check-redis-persistence.sh echo "🚀 重新启动EasyAI" -sudo $DOCKER_COMPOSE_CMD pull && sudo $DOCKER_COMPOSE_CMD up -d -echo "🎉EasyAI应用更新成功" +echo "📦 当前磁盘空间:" +df -h "$SCRIPT_DIR" | tail -n 1 +if ! sudo $DOCKER_COMPOSE_CMD pull; then + echo "❌ 镜像拉取失败,EasyAI 未被标记为更新成功" + echo " 请检查网络、镜像仓库权限和服务器磁盘空间后重试" + exit 1 +fi +if ! sudo $DOCKER_COMPOSE_CMD up -d; then + echo "❌ 容器启动失败,EasyAI 未被标记为更新成功" + exit 1 +fi +if ! wait_for_core_services; then + exit 1 +fi +echo "🎉 EasyAI 应用更新成功"