Files
easyai/update.ps1
T
wangbo 195e2e5a98
Test deployment public URL (Linux) / test-linux (push) Waiting to run
Test start.ps1 (Windows) / test-windows (push) Waiting to run
feat(deploy): 增加受控更新与环境配置迁移
2026-09-27 11:12:55 +08:00

353 lines
13 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#Requires -Version 5.1
# EasyAI Windows 更新脚本
# 拉取仓库最新代码,更新镜像并重启服务
# 用法: .\update.ps1
# 设置控制台编码为 UTF-8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$ErrorActionPreference = "Stop"
$script:LogDir = $PSScriptRoot
if (-not $script:LogDir) { $script:LogDir = $env:TEMP }
$script:LogFile = Join-Path $script:LogDir "update.ps1.log"
function Write-Log {
param([string]$Msg)
try {
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Msg"
Add-Content -Path $script:LogFile -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue
} catch { }
}
trap {
$errMsg = $_.Exception.Message
try {
Add-Content -Path $script:LogFile -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] 错误: $errMsg" -Encoding UTF8 -ErrorAction SilentlyContinue
} catch { }
Write-Host ""
Write-Host "发生错误: $errMsg" -ForegroundColor Red
if ($env:CI -ne "true") { Read-Host "按 Enter 键退出" }
exit 1
}
function Wait-ForExit {
if ($env:CI -eq "true") { return }
if ($env:UPDATE_NO_WAIT -eq "1") { return }
Write-Host ""
Read-Host "按 Enter 键退出"
}
function Write-Step { param($Msg) Write-Host $Msg }
function Write-Ok { param($Msg) Write-Host " ✓ $Msg" -ForegroundColor Green }
function Write-Err { param($Msg) Write-Host "❌ $Msg" -ForegroundColor Red; throw $Msg }
function Write-Warn { param($Msg) Write-Host "⚠️ $Msg" -ForegroundColor Yellow }
# -h/--help
if ($args -contains "-h" -or $args -contains "--help") {
Write-Host "用法: .\update.ps1"
Write-Host ""
Write-Host "默认拉取仓库并更新镜像;也可通过环境变量跳过交互选择。"
Write-Host ""
Write-Host "环境变量:"
Write-Host " UPDATE_MODE=full|image"
Write-Host " full 更新并拉取仓库(git pull)+ 更新镜像并重启"
Write-Host " image 仅更新镜像并重启(跳过 git pull)"
Write-Host " UPDATE_SKIP_REPO_UPDATE=true|false"
Write-Host " true 等同 UPDATE_MODE=image"
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
}
# 进入项目目录
$scriptDir = $PSScriptRoot
if (-not $scriptDir) { Write-Err "无法获取脚本所在目录" }
$composePath = Join-Path $scriptDir "docker-compose.yml"
if (-not (Test-Path $composePath)) { Write-Err "未找到 docker-compose.yml,请在 easyai 目录下运行 update.ps1" }
Set-Location $scriptDir
Write-Step "📁 项目目录: $scriptDir"
function ConvertTo-BoolEnv {
param([string]$Value)
$v = if ($null -eq $Value) { "" } else { $Value.Trim().ToLowerInvariant() }
switch ($v) {
{ $_ -in @("1", "true", "yes", "y", "on") } { return $true }
{ $_ -in @("0", "false", "no", "n", "off", "") } { return $false }
default { Write-Err "布尔变量值不合法: $Value(支持 true/false/1/0/yes/no/y/n/on/off)" }
}
}
function Resolve-UpdateMode {
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_SKIP_REPO_UPDATE)) {
$skip = ConvertTo-BoolEnv $env:UPDATE_SKIP_REPO_UPDATE
Write-Step "使用环境变量: UPDATE_SKIP_REPO_UPDATE=$skip"
return $skip
}
if (-not [string]::IsNullOrWhiteSpace($env:UPDATE_MODE)) {
$mode = $env:UPDATE_MODE.Trim().ToLowerInvariant()
switch ($mode) {
{ $_ -in @("full", "repo", "pull", "git", "1") } {
Write-Step "使用环境变量: UPDATE_MODE=$($env:UPDATE_MODE)"
return $false
}
{ $_ -in @("image", "images", "docker", "skip-git", "skip_repo", "skip-repo", "2") } {
Write-Step "使用环境变量: UPDATE_MODE=$($env:UPDATE_MODE)"
return $true
}
default { Write-Err "UPDATE_MODE 只能是 full 或 image,当前为: $($env:UPDATE_MODE)" }
}
}
if ($env:UPDATE_NON_INTERACTIVE -eq "1" -or $env:CI -eq "true") {
Write-Step "使用默认更新方式: 更新并拉取仓库(git pull)+ 更新镜像并重启"
return $false
}
Write-Host ""
Write-Host "请选择更新方式:"
Write-Host " [1] 更新并拉取仓库(git pull)+ 更新镜像并重启(默认)"
Write-Host " [2] 仅更新镜像并重启(跳过 git pull)"
$choice = Read-Host "请选择 [1/2,回车默认 1]"
if ([string]::IsNullOrWhiteSpace($choice)) { $choice = "1" }
switch ($choice) {
"2" { return $true }
"1" { return $false }
default {
Write-Warn "无效选择,将使用默认:更新并拉取仓库"
return $false
}
}
}
$skipRepoUpdate = Resolve-UpdateMode
# ==================== 拉取仓库 ====================
if (-not $skipRepoUpdate) {
Write-Host ""
Write-Host "==========================="
Write-Host "📥 拉取仓库最新代码"
Write-Host "==========================="
if (-not (Test-Path ".git")) {
Write-Err "当前目录不是 Git 仓库,请使用 git clone 克隆项目后使用 update.ps1"
}
$beforeRevision = (git rev-parse HEAD).Trim()
Write-Step "📥 正在执行 git pull --ff-only..."
try {
$output = git pull --ff-only 2>&1
if ($LASTEXITCODE -ne 0) { throw "git pull 返回 $LASTEXITCODE" }
Write-Ok "仓库已更新到最新版本"
} catch {
Write-Err "git pull 失败,请检查网络或远程仓库配置"
}
# 确保环境配置文件存在
Write-Host ""
Write-Step "📝 检查环境配置文件..."
if (-not (Test-Path ".env") -and (Test-Path ".env.sample")) {
Copy-Item ".env.sample" ".env"
Write-Ok ".env"
}
if (-not (Test-Path ".env.tools") -and (Test-Path ".env.tools.sample")) {
Copy-Item ".env.tools.sample" ".env.tools"
Write-Ok ".env.tools"
}
if (-not (Test-Path ".env.ASG") -and (Test-Path ".env.ASG.sample")) {
Copy-Item ".env.ASG.sample" ".env.ASG"
Write-Ok ".env.ASG"
}
if (-not (Test-Path ".env.AMS") -and (Test-Path ".env.AMS.sample")) {
Copy-Item ".env.AMS.sample" ".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 ""
} else {
Write-Host ""
Write-Step "⏭️ 跳过仓库更新,仅更新镜像并重启"
Write-Host ""
}
# 空值、历史默认 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")
. (Join-Path $scriptDir "scripts\Initialize-SecurityEnv.ps1")
Initialize-SecurityEnv -Path (Join-Path $scriptDir ".env") -Mode "upgrade"
# 在更新镜像和重建服务前迁移并校验后端公开 API 地址。
. (Join-Path $scriptDir "scripts\Initialize-PublicApiBaseUrl.ps1")
Initialize-PublicApiBaseUrl `
-Path (Join-Path $scriptDir ".env") `
-Mode "upgrade" `
-Override $env:DEPLOY_PUBLIC_API_BASE_URL
. (Join-Path $scriptDir "scripts\Initialize-SecurityOrigin.ps1")
Initialize-SecurityOrigin -Path (Join-Path $scriptDir ".env")
# ==================== Docker 检查 ====================
function Test-DockerInstalled {
$docker = Get-Command docker -ErrorAction SilentlyContinue
if (-not $docker) { return $false }
try { $null = & docker --version 2>&1; return $true } catch { return $false }
}
function Test-DockerRunning {
param([int]$TimeoutSeconds = 10)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "docker"; $psi.Arguments = "info"
$psi.RedirectStandardOutput = $true; $psi.RedirectStandardError = $true
$psi.UseShellExecute = $false; $psi.CreateNoWindow = $true
$p = [System.Diagnostics.Process]::Start($psi)
$exited = $p.WaitForExit($TimeoutSeconds * 1000)
if (-not $exited) { try { $p.Kill() } catch { }; return $false }
return ($p.ExitCode -eq 0)
} catch { return $false }
}
function Ensure-DockerRunning {
if (-not (Test-DockerInstalled)) { return $false }
if (Test-DockerRunning) {
Write-Ok "Docker 引擎已运行"
$v = docker --version 2>$null; if ($v) { Write-Host " $v" }
return $true
}
Write-Warn "Docker 未运行,请手动启动 Docker Desktop 后重新执行"
return $false
}
Write-Host "==========================="
Write-Host "🚀 检查 Docker"
Write-Host "==========================="
Write-Host ""
if (-not (Test-DockerInstalled)) {
Write-Err "未检测到 Docker,请先运行 start.ps1 完成首次部署"
}
if (-not (Ensure-DockerRunning)) {
Write-Host ""
Write-Host "请启动 Docker Desktop,确认其完全就绪后再重新运行 update.ps1"
Wait-ForExit
exit 1
}
# 检测 docker compose
. (Join-Path $scriptDir "scripts\Assert-RedisPersistence.ps1")
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 up -d
if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
} else {
docker-compose pull
if ($LASTEXITCODE -ne 0) { Write-Err "镜像拉取失败,请检查网络、镜像仓库权限和服务器磁盘空间后重试" }
docker-compose up -d
if ($LASTEXITCODE -ne 0) { Write-Err "容器启动失败,EasyAI 未被标记为更新成功" }
}
Wait-CoreServices
Write-Host ""
Write-Host "================================"
Write-Host " 更新完成"
Write-Host "================================"
Write-Host "🎉 EasyAI 应用更新成功"
Write-Host "访问地址: http://127.0.0.1:3010"
Write-Host ""
Write-Log "=== 更新完成 ==="
Wait-ForExit