feat(deploy): 增加受控更新与环境配置迁移
This commit is contained in:
+17
@@ -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=
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 = "" }
|
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 += @(
|
||||||
|
|||||||
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" "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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 " 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 " 更新完成"
|
||||||
|
|||||||
@@ -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 "📦 当前磁盘空间:"
|
||||||
|
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 应用更新成功"
|
echo "🎉 EasyAI 应用更新成功"
|
||||||
|
|||||||
Reference in New Issue
Block a user