flnxi-Wu/scripts/backup-restore.ps1

542 lines
20 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(
[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]$SkipStopAppForBackup,
[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 Get-OptionalServiceContainerId {
param([string[]]$ComposeArgs, [string]$Service)
$containerId = (& docker @ComposeArgs ps -q $Service 2>$null).Trim()
if ($LASTEXITCODE -ne 0) {
return ""
}
return $containerId
}
function Test-ComposeServiceRunning {
param([string[]]$ComposeArgs, [string]$Service)
$containerId = Get-OptionalServiceContainerId -ComposeArgs $ComposeArgs -Service $Service
if ([string]::IsNullOrWhiteSpace($containerId)) {
return $false
}
$status = (& docker inspect $containerId --format '{{.State.Status}}' 2>$null).Trim()
return ($LASTEXITCODE -eq 0 -and $status -eq "running")
}
function Stop-AppForConsistentBackup {
param([string[]]$ComposeArgs, [string]$Service)
if ($SkipStopAppForBackup) {
Write-Warn "已跳过备份停写入步骤;请确认客户现场没有人正在上传、编辑或删除素材。"
return $false
}
if (-not (Test-ComposeServiceRunning -ComposeArgs $ComposeArgs -Service $Service)) {
Write-Info "$Service 服务未运行或不存在,跳过停写入步骤。"
return $false
}
Write-Info "进入备份维护窗口:暂时停止 $Service 服务,避免备份期间继续写入数据库或素材文件。"
Invoke-DockerCompose ($ComposeArgs + @("stop", $Service))
return $true
}
function Restart-AppAfterConsistentBackup {
param([string[]]$ComposeArgs, [string]$Service)
Write-Info "备份维护窗口结束:恢复启动 $Service 服务。"
Invoke-DockerCompose ($ComposeArgs + @("up", "-d", $Service))
}
function Test-ExternalDatabase {
param([hashtable]$EnvValues)
$databaseUrl = Get-EnvValue $EnvValues "DATABASE_URL"
if ([string]::IsNullOrWhiteSpace($databaseUrl)) {
return $false
}
try {
$uri = [Uri]$databaseUrl
if (-not [string]::IsNullOrWhiteSpace($uri.Host)) {
return ($uri.Host -ne "postgres")
}
} catch {
# 继续走兼容解析,避免异常 URL 直接中断备份入口。
}
if ($databaseUrl -match '@([^/:?]+)') {
return ($Matches[1] -ne "postgres")
}
return $false
}
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 Backup-LocalDirectory {
param(
[string]$ProjectDir,
[string]$Name,
[string]$OutputPath
)
$sourcePath = Join-Path $ProjectDir $Name
if (-not (Test-Path -LiteralPath $sourcePath -PathType Container)) {
return $false
}
Compress-Archive -LiteralPath $sourcePath -DestinationPath $OutputPath -Force
Write-Ok "本地目录备份完成:$Name -> $OutputPath"
return $true
}
function Restore-LocalDirectory {
param(
[string]$ProjectDir,
[string]$Name,
[string]$InputPath
)
if (-not (Test-Path -LiteralPath $InputPath -PathType Leaf)) {
return
}
$targetPath = Join-Path $ProjectDir $Name
if (Test-Path -LiteralPath $targetPath -PathType Container) {
$beforeRestorePath = Join-Path $ProjectDir ("$Name.before-restore-" + (Get-Date -Format "yyyyMMdd-HHmmss"))
Move-Item -LiteralPath $targetPath -Destination $beforeRestorePath -Force
Write-Ok "$Name 恢复前副本:$beforeRestorePath"
}
Expand-Archive -LiteralPath $InputPath -DestinationPath $ProjectDir -Force
Write-Ok "本地目录恢复完成:$Name"
}
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" {
$appWasRunningBeforeBackup = Stop-AppForConsistentBackup -ComposeArgs $composeArgs -Service $AppService
try {
$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"
$externalObjectStorage = Test-ExternalObjectStorage $envValues
if ($SkipObjectStorageBackup) {
"已按参数跳过对象存储备份。请确认外部 S3 / MinIO 或 Docker 卷已有独立备份。" | Out-File -LiteralPath (Join-Path $targetDir "OBJECT_STORAGE_BACKUP_SKIPPED.txt") -Encoding utf8
} elseif ($externalObjectStorage) {
"检测到 S3_ENDPOINT 指向外部 S3 / MinIO。本脚本不会自动备份外部对象存储,请按客户现场方案单独备份素材对象。" | Out-File -LiteralPath (Join-Path $targetDir "EXTERNAL_OBJECT_STORAGE_BACKUP_REQUIRED.txt") -Encoding utf8
} else {
Backup-ObjectStorage -VolumeName $minioVolumeName -OutputPath (Join-Path $targetDir "minio-data.tar.gz") -HelperImage $HelperImage
}
$localDataBackedUp = Backup-LocalDirectory -ProjectDir $resolvedProjectDir -Name "data" -OutputPath (Join-Path $targetDir "data.zip")
$localUploadsBackedUp = Backup-LocalDirectory -ProjectDir $resolvedProjectDir -Name "uploads" -OutputPath (Join-Path $targetDir "uploads.zip")
$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
externalObjectStorage = $externalObjectStorage
localDirectories = [ordered]@{
data = $localDataBackedUp
uploads = $localUploadsBackedUp
}
appStoppedForConsistentBackup = $appWasRunningBeforeBackup
restoreConfirmPhrase = $RestoreConfirmPhrase
}
$manifest | ConvertTo-Json -Depth 5 | Out-File -LiteralPath (Join-Path $targetDir "manifest.json") -Encoding utf8
Write-Ok "备份目录:$targetDir"
} finally {
if ($appWasRunningBeforeBackup) {
Restart-AppAfterConsistentBackup -ComposeArgs $composeArgs -Service $AppService
}
}
}
"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,跳过数据库恢复。"
}
$externalObjectStorage = Test-ExternalObjectStorage $envValues
if ($externalObjectStorage) {
Write-Warn "检测到当前 .env 使用外部对象存储,脚本不会恢复 Compose 内置 MinIO 卷;请按客户现场对象存储备份方案恢复。"
} elseif (-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,跳过对象存储恢复。"
}
Restore-LocalDirectory -ProjectDir $resolvedProjectDir -Name "data" -InputPath (Join-Path $sourceDir "data.zip")
Restore-LocalDirectory -ProjectDir $resolvedProjectDir -Name "uploads" -InputPath (Join-Path $sourceDir "uploads.zip")
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
}