401 lines
15 KiB
PowerShell
401 lines
15 KiB
PowerShell
param(
|
||
[ValidateSet("Backup", "Restore", "Verify")]
|
||
[string]$Action = "Backup",
|
||
|
||
[string]$ProjectDir = ".",
|
||
[string]$ComposeFile = "docker-compose.prod.yml",
|
||
[string]$EnvFile = ".env",
|
||
[string]$BackupDir = "backups",
|
||
[string]$BackupPath,
|
||
|
||
[string]$AppService = "app",
|
||
[string]$PostgresService = "postgres",
|
||
[string]$MinioService = "minio",
|
||
[string]$HelperImage = "busybox:1.36",
|
||
|
||
[switch]$SkipDatabaseBackup,
|
||
[switch]$SkipObjectStorageBackup,
|
||
[switch]$SkipObjectStorageRestore,
|
||
[switch]$RestoreEnv,
|
||
|
||
[int]$HealthTimeoutSeconds = 120,
|
||
|
||
[string]$ConfirmRestore
|
||
)
|
||
|
||
Set-StrictMode -Version Latest
|
||
$ErrorActionPreference = "Stop"
|
||
|
||
$RestoreConfirmPhrase = "RESTORE_FLNXI_DATA"
|
||
|
||
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 Resolve-ExistingFile {
|
||
param([string]$Path, [string]$Label)
|
||
|
||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||
throw "$Label 不存在:$Path"
|
||
}
|
||
|
||
return (Resolve-Path -LiteralPath $Path).Path
|
||
}
|
||
|
||
function Resolve-ProjectPath {
|
||
param([string]$Path)
|
||
|
||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
||
throw "项目目录不存在:$Path"
|
||
}
|
||
|
||
return (Resolve-Path -LiteralPath $Path).Path
|
||
}
|
||
|
||
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-ComposeArgs {
|
||
param([string]$ComposeFilePath, [string]$EnvFilePath)
|
||
|
||
$args = @("compose", "-f", $ComposeFilePath)
|
||
if (Test-Path -LiteralPath $EnvFilePath -PathType Leaf) {
|
||
$args += @("--env-file", $EnvFilePath)
|
||
}
|
||
return $args
|
||
}
|
||
|
||
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 Test-ExternalDatabase {
|
||
param([hashtable]$EnvValues)
|
||
|
||
$databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL"
|
||
if ([string]::IsNullOrWhiteSpace($databaseUrl)) {
|
||
return $false
|
||
}
|
||
|
||
return ($databaseUrl -notmatch '@postgres:5432' -and $databaseUrl -notmatch '@[^/]*postgres[^/]*:5432')
|
||
}
|
||
|
||
function Backup-Database {
|
||
param([string[]]$ComposeArgs, [string]$Service, [string]$OutputPath)
|
||
|
||
Write-Info "备份 Compose 内置 PostgreSQL"
|
||
Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service))
|
||
$containerId = Get-ServiceContainerId -ComposeArgs $ComposeArgs -Service $Service
|
||
$containerBackupPath = "/tmp/flnxi-backup.sql"
|
||
|
||
Invoke-DockerCompose ($ComposeArgs + @(
|
||
"exec", "-T", $Service,
|
||
"sh", "-c",
|
||
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner --no-privileges -f /tmp/flnxi-backup.sql && test -s /tmp/flnxi-backup.sql'
|
||
))
|
||
|
||
& docker cp "${containerId}:${containerBackupPath}" $OutputPath
|
||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) {
|
||
throw "数据库备份复制失败:$OutputPath"
|
||
}
|
||
|
||
Invoke-DockerCompose ($ComposeArgs + @("exec", "-T", $Service, "sh", "-c", "rm -f $containerBackupPath"))
|
||
Write-Ok "数据库备份完成:$OutputPath"
|
||
}
|
||
|
||
function Restore-Database {
|
||
param([string[]]$ComposeArgs, [string]$Service, [string]$InputPath)
|
||
|
||
Write-Info "恢复 Compose 内置 PostgreSQL"
|
||
Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service))
|
||
$containerId = Get-ServiceContainerId -ComposeArgs $ComposeArgs -Service $Service
|
||
$containerRestorePath = "/tmp/flnxi-restore.sql"
|
||
|
||
& docker cp $InputPath "${containerId}:${containerRestorePath}"
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "数据库备份文件复制到容器失败"
|
||
}
|
||
|
||
Invoke-DockerCompose ($ComposeArgs + @(
|
||
"exec", "-T", $Service,
|
||
"sh", "-c",
|
||
'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/flnxi-restore.sql'
|
||
))
|
||
Invoke-DockerCompose ($ComposeArgs + @("exec", "-T", $Service, "sh", "-c", "rm -f $containerRestorePath"))
|
||
Write-Ok "数据库恢复完成"
|
||
}
|
||
|
||
function Backup-ObjectStorage {
|
||
param([string]$VolumeName, [string]$OutputPath, [string]$HelperImage)
|
||
|
||
if ([string]::IsNullOrWhiteSpace($VolumeName)) {
|
||
throw "MINIO_VOLUME_NAME 为空,无法备份内置对象存储卷。"
|
||
}
|
||
|
||
Write-Info "备份 Compose 内置 MinIO 数据卷:$VolumeName"
|
||
$backupDir = Split-Path -Parent $OutputPath
|
||
& docker volume inspect $VolumeName *> $null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "未找到 MinIO 数据卷:$VolumeName"
|
||
}
|
||
|
||
& docker run --rm -v "${VolumeName}:/source:ro" -v "${backupDir}:/backup" $HelperImage sh -c "cd /source && tar -czf /backup/minio-data.tar.gz ."
|
||
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $OutputPath -PathType Leaf)) {
|
||
throw "对象存储备份失败。请确认 helper 镜像可用:$HelperImage"
|
||
}
|
||
|
||
Write-Ok "对象存储备份完成:$OutputPath"
|
||
}
|
||
|
||
function Restore-ObjectStorage {
|
||
param([string]$VolumeName, [string]$InputPath, [string]$HelperImage)
|
||
|
||
if ([string]::IsNullOrWhiteSpace($VolumeName)) {
|
||
throw "MINIO_VOLUME_NAME 为空,无法恢复内置对象存储卷。"
|
||
}
|
||
|
||
$backupDir = Split-Path -Parent $InputPath
|
||
Write-Info "恢复 Compose 内置 MinIO 数据卷:$VolumeName"
|
||
& docker volume inspect $VolumeName *> $null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
& docker volume create $VolumeName | Out-Null
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "创建 MinIO 数据卷失败:$VolumeName"
|
||
}
|
||
}
|
||
|
||
& docker run --rm -v "${VolumeName}:/target" -v "${backupDir}:/backup:ro" $HelperImage sh -c "rm -rf /target/* /target/.[!.]* /target/..?* 2>/dev/null || true; cd /target && tar -xzf /backup/minio-data.tar.gz"
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "对象存储恢复失败。请确认 helper 镜像可用:$HelperImage"
|
||
}
|
||
|
||
Write-Ok "对象存储恢复完成"
|
||
}
|
||
|
||
function Save-CommandOutput {
|
||
param([string]$Path, [scriptblock]$Command)
|
||
|
||
try {
|
||
& $Command *>&1 | Out-File -LiteralPath $Path -Encoding utf8
|
||
} catch {
|
||
"命令执行失败:$($_.Exception.Message)" | Out-File -LiteralPath $Path -Encoding utf8
|
||
}
|
||
}
|
||
|
||
function Invoke-Verify {
|
||
param([string[]]$ComposeArgs, [hashtable]$EnvValues, [int]$TimeoutSeconds)
|
||
|
||
Write-Info "检查 Compose 配置"
|
||
Invoke-DockerCompose ($ComposeArgs + @("config", "--quiet"))
|
||
|
||
Write-Info "查看容器状态"
|
||
Invoke-DockerCompose ($ComposeArgs + @("ps"))
|
||
|
||
$publicUrl = Get-EnvValue $EnvValues "APP_PUBLIC_URL"
|
||
if ([string]::IsNullOrWhiteSpace($publicUrl)) {
|
||
$publicUrl = Get-EnvValue $EnvValues "NEXTAUTH_URL"
|
||
}
|
||
|
||
if ([string]::IsNullOrWhiteSpace($publicUrl)) {
|
||
Write-Warn "未配置 APP_PUBLIC_URL / NEXTAUTH_URL,跳过 /api/health HTTP 验收。"
|
||
return
|
||
}
|
||
|
||
$healthUrl = $publicUrl.TrimEnd("/") + "/api/health"
|
||
Write-Info "检查健康接口:$healthUrl"
|
||
$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"
|
||
}
|
||
|
||
$resolvedProjectDir = Resolve-ProjectPath $ProjectDir
|
||
Push-Location $resolvedProjectDir
|
||
try {
|
||
$composePath = Resolve-ExistingFile $ComposeFile "Compose 文件"
|
||
$envPath = Join-Path $resolvedProjectDir $EnvFile
|
||
$envValues = Read-EnvFile $envPath
|
||
$composeArgs = Get-ComposeArgs -ComposeFilePath $composePath -EnvFilePath $envPath
|
||
|
||
switch ($Action) {
|
||
"Backup" {
|
||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||
$backupRoot = Join-Path $resolvedProjectDir $BackupDir
|
||
$targetDir = Join-Path $backupRoot "flnxi-backup-$timestamp"
|
||
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
|
||
|
||
Copy-Item -LiteralPath $composePath -Destination (Join-Path $targetDir "docker-compose.prod.yml") -Force
|
||
if (Test-Path -LiteralPath $envPath -PathType Leaf) {
|
||
Copy-Item -LiteralPath $envPath -Destination (Join-Path $targetDir ".env") -Force
|
||
}
|
||
if (Test-Path -LiteralPath "VERSION.txt" -PathType Leaf) {
|
||
Copy-Item -LiteralPath "VERSION.txt" -Destination (Join-Path $targetDir "VERSION.txt") -Force
|
||
}
|
||
|
||
Save-CommandOutput -Path (Join-Path $targetDir "compose-config.txt") -Command { & docker @composeArgs config }
|
||
Save-CommandOutput -Path (Join-Path $targetDir "compose-ps.txt") -Command { & docker @composeArgs ps }
|
||
Save-CommandOutput -Path (Join-Path $targetDir "docker-images.txt") -Command { & docker images --digests }
|
||
|
||
$externalDb = Test-ExternalDatabase $envValues
|
||
if ($SkipDatabaseBackup) {
|
||
"已按参数跳过数据库备份。请确认外部备份已存在。" | Out-File -LiteralPath (Join-Path $targetDir "DATABASE_BACKUP_SKIPPED.txt") -Encoding utf8
|
||
} elseif ($externalDb) {
|
||
"检测到 DATABASE_URL 指向外部数据库。本脚本不会自动备份外部数据库,请按客户现场数据库方案单独备份。" | Out-File -LiteralPath (Join-Path $targetDir "EXTERNAL_DATABASE_BACKUP_REQUIRED.txt") -Encoding utf8
|
||
} else {
|
||
Backup-Database -ComposeArgs $composeArgs -Service $PostgresService -OutputPath (Join-Path $targetDir "postgres.sql")
|
||
}
|
||
|
||
$minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data"
|
||
if ($SkipObjectStorageBackup) {
|
||
"已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" | Out-File -LiteralPath (Join-Path $targetDir "OBJECT_STORAGE_BACKUP_SKIPPED.txt") -Encoding utf8
|
||
} else {
|
||
Backup-ObjectStorage -VolumeName $minioVolumeName -OutputPath (Join-Path $targetDir "minio-data.tar.gz") -HelperImage $HelperImage
|
||
}
|
||
|
||
$manifest = [ordered]@{
|
||
createdAt = (Get-Date).ToString("s")
|
||
projectDir = $resolvedProjectDir
|
||
composeFile = $composePath
|
||
envFile = $envPath
|
||
appImage = Get-EnvValue $envValues "APP_IMAGE"
|
||
appPublicUrl = Get-EnvValue $envValues "APP_PUBLIC_URL"
|
||
postgresService = $PostgresService
|
||
minioVolumeName = $minioVolumeName
|
||
externalDatabase = $externalDb
|
||
restoreConfirmPhrase = $RestoreConfirmPhrase
|
||
}
|
||
$manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $targetDir "manifest.json") -Encoding utf8
|
||
Write-Ok "备份目录:$targetDir"
|
||
}
|
||
|
||
"Restore" {
|
||
if ([string]::IsNullOrWhiteSpace($BackupPath)) {
|
||
throw "恢复必须传入 -BackupPath。"
|
||
}
|
||
if ($ConfirmRestore -ne $RestoreConfirmPhrase) {
|
||
throw "恢复会覆盖客户数据。请显式传入 -ConfirmRestore $RestoreConfirmPhrase。"
|
||
}
|
||
|
||
$sourceDir = Resolve-ProjectPath $BackupPath
|
||
$dbBackup = Join-Path $sourceDir "postgres.sql"
|
||
$objectBackup = Join-Path $sourceDir "minio-data.tar.gz"
|
||
|
||
Write-Warn "即将恢复备份目录:$sourceDir"
|
||
Invoke-DockerCompose ($composeArgs + @("stop", $AppService))
|
||
|
||
if ($RestoreEnv -and (Test-Path -LiteralPath (Join-Path $sourceDir ".env") -PathType Leaf)) {
|
||
$envBackupPath = Join-Path $resolvedProjectDir (".env.before-restore-" + (Get-Date -Format "yyyyMMdd-HHmmss"))
|
||
if (Test-Path -LiteralPath $envPath -PathType Leaf) {
|
||
Copy-Item -LiteralPath $envPath -Destination $envBackupPath -Force
|
||
}
|
||
Copy-Item -LiteralPath (Join-Path $sourceDir ".env") -Destination $envPath -Force
|
||
$envValues = Read-EnvFile $envPath
|
||
Write-Ok ".env 已恢复,恢复前副本:$envBackupPath"
|
||
}
|
||
|
||
if (Test-Path -LiteralPath $dbBackup -PathType Leaf) {
|
||
Restore-Database -ComposeArgs $composeArgs -Service $PostgresService -InputPath $dbBackup
|
||
} else {
|
||
Write-Warn "备份目录没有 postgres.sql,跳过数据库恢复。"
|
||
}
|
||
|
||
if (-not $SkipObjectStorageRestore -and (Test-Path -LiteralPath $objectBackup -PathType Leaf)) {
|
||
Invoke-DockerCompose ($composeArgs + @("stop", $MinioService))
|
||
$minioVolumeName = Get-EnvValue $envValues "MINIO_VOLUME_NAME" "flnxi-minio-data"
|
||
Restore-ObjectStorage -VolumeName $minioVolumeName -InputPath $objectBackup -HelperImage $HelperImage
|
||
} elseif ($SkipObjectStorageRestore) {
|
||
Write-Warn "已按参数跳过对象存储恢复。"
|
||
} else {
|
||
Write-Warn "备份目录没有 minio-data.tar.gz,跳过对象存储恢复。"
|
||
}
|
||
|
||
Invoke-DockerCompose ($composeArgs + @("up", "-d"))
|
||
Invoke-Verify -ComposeArgs $composeArgs -EnvValues (Read-EnvFile $envPath) -TimeoutSeconds $HealthTimeoutSeconds
|
||
Write-Ok "恢复和基础验收完成"
|
||
}
|
||
|
||
"Verify" {
|
||
Invoke-Verify -ComposeArgs $composeArgs -EnvValues $envValues -TimeoutSeconds $HealthTimeoutSeconds
|
||
}
|
||
}
|
||
} finally {
|
||
Pop-Location
|
||
}
|