361 lines
11 KiB
PowerShell
361 lines
11 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]$PreflightOnly,
|
||
[switch]$PrepareDirectories,
|
||
[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 Write-Warn {
|
||
param([string]$Message)
|
||
Write-Warning $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 Test-PlaceholderValue {
|
||
param([string]$Value)
|
||
|
||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||
return $true
|
||
}
|
||
|
||
$trimmed = $Value.Trim()
|
||
if ($trimmed -match '^(replace-with|your-|<|>)') {
|
||
return $true
|
||
}
|
||
|
||
if ($trimmed -match 'example\.com' -or $trimmed -match '192\.168\.x\.x' -or $trimmed -match '\bx\.x\b') {
|
||
return $true
|
||
}
|
||
|
||
return $false
|
||
}
|
||
|
||
function Add-Problem {
|
||
param(
|
||
[System.Collections.Generic.List[string]]$Problems,
|
||
[string]$Message
|
||
)
|
||
|
||
$Problems.Add($Message) | Out-Null
|
||
}
|
||
|
||
function Test-RequiredEnv {
|
||
param(
|
||
[hashtable]$EnvValues,
|
||
[System.Collections.Generic.List[string]]$Problems,
|
||
[string]$Name
|
||
)
|
||
|
||
$value = Get-EnvValue $EnvValues $Name
|
||
if (Test-PlaceholderValue $value) {
|
||
Add-Problem $Problems "$Name 还不是客户现场真实值"
|
||
}
|
||
}
|
||
|
||
function Test-AppImage {
|
||
param(
|
||
[hashtable]$EnvValues,
|
||
[System.Collections.Generic.List[string]]$Problems
|
||
)
|
||
|
||
$appImage = Get-EnvValue $EnvValues "APP_IMAGE"
|
||
if (Test-PlaceholderValue $appImage) {
|
||
Add-Problem $Problems "APP_IMAGE 还不是完整客户版本镜像"
|
||
return
|
||
}
|
||
|
||
$imageName = ($appImage -split '/')[-1]
|
||
if ($imageName -match ':latest$') {
|
||
Add-Problem $Problems "APP_IMAGE 不能使用 latest,必须固定到明确版本号"
|
||
}
|
||
|
||
if ($appImage -notmatch '@sha256:' -and $imageName -notmatch ':.+') {
|
||
Add-Problem $Problems "APP_IMAGE 缺少版本标签,例如 :1.0.1"
|
||
}
|
||
|
||
if ($appImage -match '/material-dev/') {
|
||
Write-Warn "APP_IMAGE 当前指向 material-dev 测试镜像;正式客户交付应使用 material 正式镜像。"
|
||
}
|
||
}
|
||
|
||
function Test-DirectoryReady {
|
||
param(
|
||
[string]$ProjectDir,
|
||
[System.Collections.Generic.List[string]]$Problems,
|
||
[string]$Name,
|
||
[switch]$Prepare
|
||
)
|
||
|
||
$path = Join-Path $ProjectDir $Name
|
||
if (-not (Test-Path -LiteralPath $path -PathType Container)) {
|
||
if ($Prepare) {
|
||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||
Write-Ok "已创建目录:$Name"
|
||
} else {
|
||
Add-Problem $Problems "缺少 $Name 目录;可先创建并确保应用容器用户可写"
|
||
return
|
||
}
|
||
}
|
||
|
||
$testFile = Join-Path $path ".flnxi-write-test"
|
||
try {
|
||
"ok" | Out-File -LiteralPath $testFile -Encoding utf8 -Force
|
||
Remove-Item -LiteralPath $testFile -Force
|
||
} catch {
|
||
Add-Problem $Problems "$Name 目录当前不可写:$($_.Exception.Message)"
|
||
}
|
||
}
|
||
|
||
function Invoke-InstallPreflight {
|
||
param(
|
||
[string]$ProjectDir,
|
||
[string]$EnvPath,
|
||
[hashtable]$EnvValues,
|
||
[switch]$PrepareDirectories
|
||
)
|
||
|
||
Write-Info "执行安装前检查"
|
||
$problems = [System.Collections.Generic.List[string]]::new()
|
||
|
||
if (-not (Test-Path -LiteralPath $EnvPath -PathType Leaf)) {
|
||
Add-Problem $problems ".env 文件不存在,请先从 .env.example 复制并填写客户现场值"
|
||
}
|
||
|
||
foreach ($name in @(
|
||
"APP_PUBLIC_URL",
|
||
"NEXTAUTH_URL",
|
||
"NEXTAUTH_SECRET",
|
||
"LICENSE_SERVER_URL",
|
||
"LICENSE_PROJECT_CODE",
|
||
"LICENSE_REQUEST_SIGNING_SECRET",
|
||
"LICENSE_PUBLIC_KEY",
|
||
"LOCAL_ENCRYPTION_KEY",
|
||
"POSTGRES_PASSWORD",
|
||
"MINIO_ROOT_USER",
|
||
"MINIO_ROOT_PASSWORD",
|
||
"S3_PUBLIC_DOMAIN"
|
||
)) {
|
||
Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name
|
||
}
|
||
|
||
Test-AppImage -EnvValues $EnvValues -Problems $problems
|
||
|
||
$databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL"
|
||
if (-not [string]::IsNullOrWhiteSpace($databaseUrl) -and (Test-PlaceholderValue $databaseUrl)) {
|
||
Add-Problem $problems "DATABASE_URL 仍是示例地址;内置 PostgreSQL 请留空,外部 PostgreSQL 请填真实连接串"
|
||
}
|
||
|
||
$appPublicUrl = (Get-EnvValue $EnvValues "APP_PUBLIC_URL").TrimEnd("/")
|
||
$nextAuthUrl = (Get-EnvValue $EnvValues "NEXTAUTH_URL").TrimEnd("/")
|
||
if (-not [string]::IsNullOrWhiteSpace($appPublicUrl) -and -not [string]::IsNullOrWhiteSpace($nextAuthUrl) -and $appPublicUrl -ne $nextAuthUrl) {
|
||
Write-Warn "APP_PUBLIC_URL 与 NEXTAUTH_URL 不一致,请确认最终浏览器访问入口。"
|
||
}
|
||
|
||
Test-DirectoryReady -ProjectDir $ProjectDir -Problems $problems -Name "uploads" -Prepare:$PrepareDirectories
|
||
Test-DirectoryReady -ProjectDir $ProjectDir -Problems $problems -Name "data" -Prepare:$PrepareDirectories
|
||
|
||
if ($problems.Count -gt 0) {
|
||
$message = "安装前检查未通过:`n - " + ($problems -join "`n - ")
|
||
throw $message
|
||
}
|
||
|
||
Write-Ok "安装前检查通过"
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
Invoke-InstallPreflight -ProjectDir $resolvedProjectDir -EnvPath $envPath -EnvValues $envValues -PrepareDirectories:$PrepareDirectories
|
||
if ($PreflightOnly) {
|
||
Write-Ok "安装前检查完成,未启动或检查容器。"
|
||
return
|
||
}
|
||
|
||
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"
|
||
|
||
$versionCommand = "cat VERSION.txt 2>/dev/null || true"
|
||
$versionOutput = & docker @composeArgs exec -T $AppService sh -c $versionCommand
|
||
$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
|
||
}
|