196 lines
6.0 KiB
PowerShell
196 lines
6.0 KiB
PowerShell
param(
|
||
[string]$ProjectDir = ".",
|
||
[string]$ComposeFile = "docker-compose.prod.yml",
|
||
[string]$EnvFile = ".env",
|
||
[string]$AppService = "app",
|
||
[string]$PostgresService = "postgres",
|
||
[string]$MinioService = "minio",
|
||
[switch]$Start,
|
||
[switch]$SkipMigrationStatus,
|
||
[switch]$SkipObjectStorageCheck,
|
||
[int]$HealthTimeoutSeconds = 120
|
||
)
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = "Stop"
|
||
|
||
function Write-Info {
|
||
param([string]$Message)
|
||
Write-Host "[INFO] $Message"
|
||
}
|
||
|
||
function Write-Ok {
|
||
param([string]$Message)
|
||
Write-Host "[OK] $Message"
|
||
}
|
||
|
||
function Read-EnvFile {
|
||
param([string]$Path)
|
||
|
||
$values = @{}
|
||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||
return $values
|
||
}
|
||
|
||
foreach ($line in Get-Content -LiteralPath $Path) {
|
||
if ($line -match '^\s*#' -or $line -notmatch '=') {
|
||
continue
|
||
}
|
||
|
||
$parts = $line -split '=', 2
|
||
$key = $parts[0].Trim()
|
||
if ([string]::IsNullOrWhiteSpace($key)) {
|
||
continue
|
||
}
|
||
|
||
$value = $parts[1].Trim()
|
||
if (($value.StartsWith('"') -and $value.EndsWith('"')) -or ($value.StartsWith("'") -and $value.EndsWith("'"))) {
|
||
$value = $value.Substring(1, $value.Length - 2)
|
||
}
|
||
$values[$key] = $value
|
||
}
|
||
|
||
return $values
|
||
}
|
||
|
||
function Get-EnvValue {
|
||
param([hashtable]$EnvValues, [string]$Name, [string]$Default = "")
|
||
|
||
if ($EnvValues.ContainsKey($Name)) {
|
||
return $EnvValues[$Name]
|
||
}
|
||
|
||
return $Default
|
||
}
|
||
|
||
function Invoke-DockerCompose {
|
||
param([string[]]$Arguments)
|
||
|
||
& docker @Arguments
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "docker 命令失败,退出码:$LASTEXITCODE"
|
||
}
|
||
}
|
||
|
||
function Get-ServiceContainerId {
|
||
param([string[]]$ComposeArgs, [string]$Service)
|
||
|
||
$containerId = (& docker @ComposeArgs ps -q $Service).Trim()
|
||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) {
|
||
throw "未找到服务容器:$Service。"
|
||
}
|
||
|
||
return $containerId
|
||
}
|
||
|
||
function Wait-Health {
|
||
param([string]$HealthUrl, [int]$TimeoutSeconds)
|
||
|
||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||
do {
|
||
try {
|
||
$response = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 10
|
||
if ($response.StatusCode -eq 200) {
|
||
Write-Ok "/api/health 正常"
|
||
return
|
||
}
|
||
} catch {
|
||
Start-Sleep -Seconds 5
|
||
}
|
||
} while ((Get-Date) -lt $deadline)
|
||
|
||
throw "/api/health 在 $TimeoutSeconds 秒内没有通过:$HealthUrl"
|
||
}
|
||
|
||
function Wait-ServiceRunning {
|
||
param([string]$ContainerId, [string]$Service, [int]$TimeoutSeconds)
|
||
|
||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||
do {
|
||
$status = (& docker inspect $ContainerId --format '{{.State.Status}}').Trim()
|
||
if ($LASTEXITCODE -eq 0 -and $status -eq "running") {
|
||
Write-Ok "$Service 容器已运行"
|
||
return
|
||
}
|
||
Start-Sleep -Seconds 5
|
||
} while ((Get-Date) -lt $deadline)
|
||
|
||
throw "$Service 容器在 $TimeoutSeconds 秒内没有稳定运行。"
|
||
}
|
||
|
||
$resolvedProjectDir = (Resolve-Path -LiteralPath $ProjectDir).Path
|
||
Push-Location $resolvedProjectDir
|
||
try {
|
||
if (-not (Test-Path -LiteralPath $ComposeFile -PathType Leaf)) {
|
||
throw "Compose 文件不存在:$ComposeFile"
|
||
}
|
||
|
||
$composePath = (Resolve-Path -LiteralPath $ComposeFile).Path
|
||
$envPath = Join-Path $resolvedProjectDir $EnvFile
|
||
$envValues = Read-EnvFile $envPath
|
||
$composeArgs = @("compose", "-f", $composePath)
|
||
if (Test-Path -LiteralPath $envPath -PathType Leaf) {
|
||
$composeArgs += @("--env-file", $envPath)
|
||
}
|
||
|
||
Write-Info "检查 Compose 配置"
|
||
Invoke-DockerCompose ($composeArgs + @("config", "--quiet"))
|
||
|
||
if ($Start) {
|
||
Write-Info "启动客户安装编排"
|
||
Invoke-DockerCompose ($composeArgs + @("up", "-d"))
|
||
}
|
||
|
||
Write-Info "检查容器状态"
|
||
Invoke-DockerCompose ($composeArgs + @("ps"))
|
||
|
||
$appContainerId = Get-ServiceContainerId -ComposeArgs $composeArgs -Service $AppService
|
||
Wait-ServiceRunning -ContainerId $appContainerId -Service $AppService -TimeoutSeconds $HealthTimeoutSeconds
|
||
|
||
$runningImage = (& docker inspect $appContainerId --format '{{.Config.Image}}').Trim()
|
||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($runningImage)) {
|
||
throw "无法读取应用运行镜像。"
|
||
}
|
||
Write-Ok "应用运行镜像:$runningImage"
|
||
|
||
$versionOutput = & docker @composeArgs exec -T $AppService sh -c 'cat VERSION.txt 2>/dev/null || true'
|
||
$version = "$versionOutput".Trim()
|
||
if (-not [string]::IsNullOrWhiteSpace($version)) {
|
||
Write-Ok "应用版本文件:$version"
|
||
} else {
|
||
Write-Host "[WARN] 镜像内未读到 VERSION.txt,请确认发布镜像是否包含版本文件。"
|
||
}
|
||
|
||
$publicUrl = Get-EnvValue $envValues "APP_PUBLIC_URL"
|
||
if ([string]::IsNullOrWhiteSpace($publicUrl)) {
|
||
$publicUrl = Get-EnvValue $envValues "NEXTAUTH_URL"
|
||
}
|
||
if ([string]::IsNullOrWhiteSpace($publicUrl)) {
|
||
throw "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,无法验收健康接口。"
|
||
}
|
||
|
||
Wait-Health -HealthUrl ($publicUrl.TrimEnd("/") + "/api/health") -TimeoutSeconds $HealthTimeoutSeconds
|
||
|
||
if (-not $SkipMigrationStatus) {
|
||
Write-Info "检查数据库迁移状态"
|
||
Invoke-DockerCompose ($composeArgs + @("exec", "-T", $AppService, "npx", "prisma", "migrate", "status", "--schema=prisma/schema.prisma"))
|
||
}
|
||
|
||
if (-not $SkipObjectStorageCheck) {
|
||
$bucket = Get-EnvValue $envValues "S3_BUCKET" "flnxi-materials"
|
||
Write-Info "检查 MinIO bucket:$bucket"
|
||
Invoke-DockerCompose ($composeArgs + @("exec", "-T", $MinioService, "sh", "-c", "test -d /data/$bucket"))
|
||
Write-Ok "对象存储 bucket 存在:$bucket"
|
||
}
|
||
|
||
Write-Info "最近应用日志"
|
||
& docker @composeArgs logs --tail 80 $AppService
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "读取应用日志失败。"
|
||
}
|
||
|
||
Write-Ok "首装基础验收完成"
|
||
} finally {
|
||
Pop-Location
|
||
}
|