forked from wangbo/easyai
feat(deploy): 增加受控更新与环境配置迁移
This commit is contained in:
+17
@@ -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=
|
||||
|
||||
@@ -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 版一致:
|
||||
|
||||
+69
-5
@@ -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
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 += @(
|
||||
|
||||
Executable
+151
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+57
@@ -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
@@ -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 " 更新完成"
|
||||
|
||||
@@ -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 "📦 当前磁盘空间:"
|
||||
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 应用更新成功"
|
||||
|
||||
Reference in New Issue
Block a user