flnxi-Wu/scripts/smoke-install.ps1
2026-06-01 10:02:30 +08:00

361 lines
11 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.

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
}