forked from wangbo/easyai
354 lines
14 KiB
PowerShell
354 lines
14 KiB
PowerShell
#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
|
||
# Browser origin is optional. Blank means allow all; explicit values are validated.
|
||
. (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
|