flnxi-Wu/scripts/smoke-install.ps1
2026-06-01 17:28:00 +08:00

424 lines
14 KiB
PowerShell
Raw Permalink 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",
[string]$ExpectedVersion = "",
[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]
$firstPart = ($appImage -split '/', 2)[0]
if ($appImage -notlike "*/*" -or ($firstPart -ne "localhost" -and $firstPart -notmatch '[.:]')) {
Add-Problem $Problems "APP_IMAGE 必须写完整镜像站地址,例如 registry.example.com:5000/flnxi/material/flnxi-material:1.0.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-ExternalObjectStorage {
param([hashtable]$EnvValues)
$endpoint = Get-EnvValue $EnvValues "S3_ENDPOINT"
if ([string]::IsNullOrWhiteSpace($endpoint)) {
return $false
}
try {
$uri = [Uri]$endpoint
return ($uri.Host -ne "minio")
} catch {
return $false
}
}
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",
"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 请填真实连接串"
}
$externalObjectStorage = Test-ExternalObjectStorage $EnvValues
if ($externalObjectStorage) {
foreach ($name in @("S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY")) {
Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name
}
} else {
foreach ($name in @("MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD", "S3_BUCKET")) {
Test-RequiredEnv -EnvValues $EnvValues -Problems $problems -Name $name
}
}
$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, [string]$ExpectedVersion = "")
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
$lastVersion = ""
do {
try {
$response = Invoke-WebRequest -Uri $HealthUrl -UseBasicParsing -TimeoutSec 10
if ($response.StatusCode -eq 200) {
if ([string]::IsNullOrWhiteSpace($ExpectedVersion)) {
Write-Ok "/api/health 正常"
return
}
try {
$healthData = $response.Content | ConvertFrom-Json
$lastVersion = "$($healthData.version)".Trim()
} catch {
$lastVersion = ""
}
if ($lastVersion -eq $ExpectedVersion) {
Write-Ok "/api/health 正常,版本符合预期:$ExpectedVersion"
return
}
}
} catch {
Start-Sleep -Seconds 5
}
} while ((Get-Date) -lt $deadline)
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
$actual = if ([string]::IsNullOrWhiteSpace($lastVersion)) { "未返回" } else { $lastVersion }
throw "/api/health 在 $TimeoutSeconds 秒内没有返回期望版本:实际 $actual,预期 $ExpectedVersion"
}
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,请确认发布镜像是否包含版本文件。"
}
if (-not [string]::IsNullOrWhiteSpace($ExpectedVersion)) {
if ([string]::IsNullOrWhiteSpace($version)) {
throw "镜像内未读到 VERSION.txt,无法确认期望版本:$ExpectedVersion"
}
if ($version -ne $ExpectedVersion) {
throw "应用版本不符合预期:实际 $version,预期 $ExpectedVersion"
}
Write-Ok "应用版本符合预期:$ExpectedVersion"
}
$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 -ExpectedVersion $ExpectedVersion
if (-not $SkipMigrationStatus) {
Write-Info "检查数据库迁移状态"
Invoke-DockerCompose ($composeArgs + @("exec", "-T", $AppService, "npx", "prisma", "migrate", "status", "--schema=prisma/schema.prisma"))
}
$externalObjectStorage = Test-ExternalObjectStorage $envValues
if ($externalObjectStorage -and -not $SkipObjectStorageCheck) {
Write-Warn "检测到外部对象存储,跳过内置 MinIO bucket 检查;请按客户现场对象存储方案单独验收。"
} elseif (-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
}