feat(deploy): 增加受控更新与环境配置迁移
Test deployment public URL (Linux) / test-linux (push) Waiting to run
Test start.ps1 (Windows) / test-windows (push) Waiting to run

This commit is contained in:
2026-09-27 11:12:55 +08:00
parent 74ada0b0bf
commit 195e2e5a98
12 changed files with 667 additions and 17 deletions
+17
View File
@@ -253,6 +253,23 @@ STATIC_CDN_FORCE_UPLOAD=false
# ========== 15. 版本 ========== # ========== 15. 版本 ==========
VERSION=latest 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 解析图片上传) ========== # ========== 16. OSS 配置(可选,PDF 解析图片上传) ==========
# 可填写 .env.tools 或在此覆盖 # 可填写 .env.tools 或在此覆盖
# OSS_ENDPOINT= # OSS_ENDPOINT=
+25
View File
@@ -172,9 +172,33 @@ mv easyai easyai2
update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能: update.sh 脚本用于自动更新 EasyAI 应用,包含以下功能:
- **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件) - **拉取整个仓库**:执行 `git pull` 获取最新代码(docker-compose.yml、start.sh、.env.*.sample 等全部文件)
- 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件) - 自动补齐缺失的环境配置文件(.env、.env.tools、.env.ASG、.env.AMS,从 .sample 生成且不覆盖已有文件)
- 每次默认执行 `scripts/update-env.d/` 中的版本化环境配置迁移,只补缺失/空值和必要列表项,不覆盖已有非空配置
- 仓库版本发生变化时自动切换到拉取后的新版更新脚本继续执行,确保本次新增的迁移立即生效
- 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL` - 在重建服务前迁移并校验 `CONFIG_PUBLIC_API_BASE_URL`
- 兼容 `docker compose` 和 `docker-compose` 两种命令格式 - 兼容 `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. [首次执行,后续无需重复执行]添加执行权限,命令: 1. [首次执行,后续无需重复执行]添加执行权限,命令:
@@ -213,6 +237,7 @@ UPDATE_SKIP_REPO_UPDATE=true ./update.sh
- 脚本会执行 `git pull` 拉取整个仓库最新代码 - 脚本会执行 `git pull` 拉取整个仓库最新代码
- 拉取后会检查并补齐缺失的 .env、.env.tools、.env.ASG、.env.AMS(不会覆盖已有文件) - 拉取后会检查并补齐缺失的 .env、.env.tools、.env.ASG、.env.AMS(不会覆盖已有文件)
- 最后执行 `docker compose pull` 和 `docker compose up -d` 拉取镜像并重启服务 - 最后执行 `docker compose pull` 和 `docker compose up -d` 拉取镜像并重启服务
- 默认最多等待 300 秒确认核心服务恢复可用;可通过 `UPDATE_HEALTH_TIMEOUT_SECONDS` 调整
### Windows 用户(update.ps1) ### Windows 用户(update.ps1)
Windows 下使用 `update.ps1`,功能与 Linux 版一致: Windows 下使用 `update.ps1`,功能与 Linux 版一致:
+69 -5
View File
@@ -8,7 +8,7 @@ services:
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION} image: registry.cn-shanghai.aliyuncs.com/comfy-ai/one-ai:${VERSION}
#端口冲突时,只需要修改前面的端口比如修改为,3011:3010 #端口冲突时,只需要修改前面的端口比如修改为,3011:3010
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
ports: ports:
- "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${WEB_PORT}:3010" - "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${WEB_PORT}:3010"
# read_only: true # read_only: true
@@ -41,6 +41,12 @@ services:
options: options:
max-size: "100m" max-size: "100m"
max-file: "10" 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: deploy:
resources: resources:
@@ -53,7 +59,7 @@ services:
# 阿里云镜像地址 # 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云 image: registry.cn-shanghai.aliyuncs.com/comfy-ai/comfy-server:${VERSION} # 阿里云
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
ports: ports:
- "${SERVER_HTTP_BIND_IP:-127.0.0.1}:${SERVER_HTTP_PORT}:3001" # Linux Nginx 部署默认仅回环;Windows/LAN 直连由启动脚本显式覆盖 - "${SERVER_HTTP_BIND_IP:-127.0.0.1}:${SERVER_HTTP_PORT}:3001" # Linux Nginx 部署默认仅回环;Windows/LAN 直连由启动脚本显式覆盖
# read_only: true # read_only: true
@@ -133,11 +139,16 @@ services:
# easyai-server 发布事件到 ws-gateway(默认走容器内网服务名) # easyai-server 发布事件到 ws-gateway(默认走容器内网服务名)
- WS_GATEWAY_TCP_HOST=${WS_GATEWAY_TCP_HOST:-ws-gateway} - WS_GATEWAY_TCP_HOST=${WS_GATEWAY_TCP_HOST:-ws-gateway}
- WS_GATEWAY_TCP_PORT=${WS_GATEWAY_TCP_PORT:-4002} - 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_file:
- .env - .env
healthcheck: 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 interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -152,7 +163,7 @@ services:
# 阿里云镜像地址 # 阿里云镜像地址
image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:latest # 阿里云 image: registry.cn-shanghai.aliyuncs.com/easyaigc/wsgateway:latest # 阿里云
labels: labels:
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=${WATCHTOWER_CORE_UPDATE_ENABLED:-true}"
ports: ports:
- "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${CONFIG_WS_PORT:-3002}:3002" # Linux IP/域名模式默认仅供同机 Nginx 访问 - "${EASYAI_PROXY_BIND_IP:-127.0.0.1}:${CONFIG_WS_PORT:-3002}:3002" # Linux IP/域名模式默认仅供同机 Nginx 访问
read_only: true read_only: true
@@ -187,6 +198,8 @@ services:
- GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001} - GATEWAY_INBOUND_TCP_PORT=${GATEWAY_INBOUND_TCP_PORT:-4001}
- GATEWAY_INBOUND_TCP_PATTERN=${GATEWAY_INBOUND_TCP_PATTERN:-gateway.inbound.command} - GATEWAY_INBOUND_TCP_PATTERN=${GATEWAY_INBOUND_TCP_PATTERN:-gateway.inbound.command}
- GATEWAY_INBOUND_TIMEOUT_MS=${GATEWAY_INBOUND_TIMEOUT_MS:-5000} - GATEWAY_INBOUND_TIMEOUT_MS=${GATEWAY_INBOUND_TIMEOUT_MS:-5000}
- WS_GATEWAY_HEALTH_PORT=3012
- EASYAI_BUILD_ID=${EASYAI_BUILD_ID:-unknown}
# 日志大小设置,避免日志文件过大 # 日志大小设置,避免日志文件过大
env_file: env_file:
- .env - .env
@@ -195,6 +208,12 @@ services:
options: options:
max-size: "100m" max-size: "100m"
max-file: "10" 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: mongo:
# 镜像见 .env 中 MONGO_IMAGE;4.4 备选地址与用途见 .env.sample 注释 # 镜像见 .env 中 MONGO_IMAGE;4.4 备选地址与用途见 .env.sample 注释
image: ${MONGO_IMAGE:-registry.cn-shanghai.aliyuncs.com/comfy-ai/mongo-aliyun:latest} 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 image: registry.cn-shanghai.aliyuncs.com/comfy-ai/watchtower-aliyun:latest
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /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 restart: always
labels: labels:
- "com.centurylinklabs.watchtower.enable=false" - "com.centurylinklabs.watchtower.enable=false"
@@ -294,6 +318,46 @@ services:
options: options:
max-size: "100m" max-size: "100m"
max-file: "10" 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: video-edit:
image: registry.cn-shanghai.aliyuncs.com/easyaigc/videoedit:latest image: registry.cn-shanghai.aliyuncs.com/easyaigc/videoedit:latest
container_name: video-edit container_name: video-edit
+111
View File
@@ -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]
}
}
}
+2 -1
View File
@@ -57,7 +57,8 @@ function Initialize-SecurityEnv {
if ($null -eq $content) { $content = "" } if ($null -eq $content) { $content = "" }
$definitions = @( $definitions = @(
@{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 }, @{ Key = "CONFIG_JWT_SECRET"; Legacy = "this is a very secret secret"; MinLength = 32 },
@{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 } @{ Key = "WS_AUTH_WS_TICKET_SECRET"; Legacy = ""; MinLength = 32 },
@{ Key = "SYSTEM_UPDATE_INTERNAL_TOKEN"; Legacy = ""; MinLength = 32 }
) )
if ($Mode -eq "new") { if ($Mode -eq "new") {
$definitions += @( $definitions += @(
+151
View File
@@ -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"
+1
View File
@@ -129,6 +129,7 @@ init_security_env() {
ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret" ensure_security_secret "$file" "CONFIG_JWT_SECRET" "this is a very secret secret"
ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET" ensure_security_secret "$file" "WS_AUTH_WS_TICKET_SECRET"
ensure_security_secret "$file" "SYSTEM_UPDATE_INTERNAL_TOKEN"
ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer" ensure_env_list_item "$file" "WS_AUTH_METHODS" "ws_ticket" "none,bearer"
if [ "$mode" = "new" ]; then if [ "$mode" = "new" ]; then
ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY" ensure_security_secret "$file" "CONFIG_SECURITY_CONFIG_ENCRYPTION_KEY"
+58
View File
@@ -144,10 +144,66 @@ watchtower = services["watchtower"]
command = " ".join(watchtower.get("command") or []) command = " ".join(watchtower.get("command") or [])
assert "http-api-update" not in command, command assert "http-api-update" not in command, command
assert "WATCHTOWER_HTTP_API_TOKEN" not in (watchtower.get("environment") or {}), watchtower.get("environment") 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 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 sed -i.bak 's/^SERVER_HTTP_PORT=.*/SERVER_HTTP_PORT=4100/' .env.sample
rm -f .env.sample.bak rm -f .env.sample.bak
DEPLOY_NON_INTERACTIVE=1 \ 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_canvas_ws_auth_config
assert_compose_security assert_compose_security
assert_redis_startup_contract assert_redis_startup_contract
assert_managed_update_contract
assert_legacy_update_contract
fi fi
reset_case reset_case
+57
View File
@@ -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"
@@ -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
+89 -6
View File
@@ -58,6 +58,10 @@ if ($args -contains "-h" -or $args -contains "--help") {
Write-Host " false 等同 UPDATE_MODE=full" Write-Host " false 等同 UPDATE_MODE=full"
Write-Host " UPDATE_NO_WAIT=1" Write-Host " UPDATE_NO_WAIT=1"
Write-Host " 结束或失败时不等待按 Enter" Write-Host " 结束或失败时不等待按 Enter"
Write-Host " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
Write-Host " 等待核心服务恢复可用的最长时间,默认 300 秒"
Write-Host ""
Write-Host "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。"
exit 0 exit 0
} }
@@ -136,9 +140,10 @@ if (-not $skipRepoUpdate) {
Write-Err "当前目录不是 Git 仓库,请使用 git clone 克隆项目后使用 update.ps1" Write-Err "当前目录不是 Git 仓库,请使用 git clone 克隆项目后使用 update.ps1"
} }
Write-Step "📥 正在执行 git pull..." $beforeRevision = (git rev-parse HEAD).Trim()
Write-Step "📥 正在执行 git pull --ff-only..."
try { try {
$output = git pull 2>&1 $output = git pull --ff-only 2>&1
if ($LASTEXITCODE -ne 0) { throw "git pull 返回 $LASTEXITCODE" } if ($LASTEXITCODE -ne 0) { throw "git pull 返回 $LASTEXITCODE" }
Write-Ok "仓库已更新到最新版本" Write-Ok "仓库已更新到最新版本"
} catch { } catch {
@@ -165,6 +170,16 @@ if (-not $skipRepoUpdate) {
Write-Ok ".env.AMS" 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 "" Write-Host ""
} else { } else {
Write-Host "" Write-Host ""
@@ -174,6 +189,12 @@ if (-not $skipRepoUpdate) {
# 空值、历史默认 JWT 密钥和缺失的 WS ticket 密钥只初始化一次并写回 .env;已有强密钥保持不变。 # 空值、历史默认 JWT 密钥和缺失的 WS ticket 密钥只初始化一次并写回 .env;已有强密钥保持不变。
if (-not (Test-Path ".env")) { Write-Err "未找到 .env,请先执行 start.ps1 完成初始化" } 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") . (Join-Path $scriptDir "scripts\Initialize-ServerHttpBindIp.ps1")
Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env") Initialize-ServerHttpBindIp -Path (Join-Path $scriptDir ".env")
@@ -243,21 +264,83 @@ Assert-RedisPersistence -ProjectRoot $scriptDir
$hasComposeV2 = $false $hasComposeV2 = $false
try { $null = docker compose version 2>&1; $hasComposeV2 = $true } catch { } 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-Host ""
Write-Step "🚀 重新启动 EasyAI..." 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) { if ($hasComposeV2) {
docker compose pull docker compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "docker compose pull 失败" } if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
docker compose up -d docker compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "docker compose up 失败" } if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
} else { } else {
docker-compose pull docker-compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "docker-compose pull 失败" } if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
docker-compose up -d 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 "================================" Write-Host "================================"
Write-Host " 更新完成" Write-Host " 更新完成"
+76 -5
View File
@@ -15,6 +15,10 @@ if [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
echo " UPDATE_SKIP_REPO_UPDATE=true|false" echo " UPDATE_SKIP_REPO_UPDATE=true|false"
echo " true 等同 UPDATE_MODE=image" echo " true 等同 UPDATE_MODE=image"
echo " false 等同 UPDATE_MODE=full" echo " false 等同 UPDATE_MODE=full"
echo " UPDATE_HEALTH_TIMEOUT_SECONDS=秒数"
echo " 等待核心服务恢复可用的最长时间,默认 300 秒"
echo ""
echo "仓库更新后会自动执行 scripts/update-env.d 中的幂等环境配置迁移。"
exit 0 exit 0
fi fi
@@ -100,8 +104,9 @@ if [ "$SKIP_REPO_UPDATE" = false ]; then
exit 1 exit 1
fi fi
echo "📥 正在执行 git pull..." before_revision="$(git rev-parse HEAD)"
if git pull; then echo "📥 正在执行 git pull --ff-only..."
if git pull --ff-only; then
echo "✅ 仓库已更新到最新版本" echo "✅ 仓库已更新到最新版本"
else else
echo "❌ git pull 失败,请检查网络或远程仓库配置" 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.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" [ ! -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 "" echo ""
else else
echo "⏭️ 跳过仓库更新,仅更新镜像并重启" echo "⏭️ 跳过仓库更新,仅更新镜像并重启"
@@ -129,6 +140,10 @@ if [ ! -f .env ]; then
exit 1 exit 1
fi fi
# 每次更新都执行版本化、幂等的环境配置迁移。迁移只补缺失/空值或必要的列表项,
# 不覆盖用户已经配置的非空值。后续新增必需配置时只需增加编号迁移文件。
bash ./scripts/apply-update-env-migrations.sh .env "$SCRIPT_DIR"
# 老部署缺少绑定配置时,根据现有公开 API 地址迁移:域名/本机保持回环,IP 直连保持可用。 # 老部署缺少绑定配置时,根据现有公开 API 地址迁移:域名/本机保持回环,IP 直连保持可用。
# shellcheck source=scripts/init-server-http-bind-ip.sh # shellcheck source=scripts/init-server-http-bind-ip.sh
. ./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 sudo apt install -y apt-transport-https ca-certificates curl software-properties-common gnupg lsb-release
echo "🔑 添加 Docker GPG 密钥..." 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 源..." echo "🌍 添加 Docker 源..."
sudo add-apt-repository -y "deb [arch=amd64] https://mirrors.nwafu.edu.cn生成注释ker-ce/linux/ubuntu/ $(lsb_release -cs) stable" 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 已就绪!" 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 会丢失数据。 # 旧 Redis 若仍把 AOF/RDB 留在容器写层,直接 up 会丢失数据。
sudo ./scripts/check-redis-persistence.sh sudo ./scripts/check-redis-persistence.sh
echo "🚀 重新启动EasyAI" echo "🚀 重新启动EasyAI"
sudo $DOCKER_COMPOSE_CMD pull && sudo $DOCKER_COMPOSE_CMD up -d echo "📦 当前磁盘空间:"
echo "🎉EasyAI应用更新成功" 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 应用更新成功"