#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