param( [string]$ContainerName = 'vignette-dev-db', [string]$BackupDirectory = '', [string]$DatabaseName = '', [string]$DatabaseUser = '' ) $ErrorActionPreference = 'Stop' [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) $OutputEncoding = [System.Text.UTF8Encoding]::new($false) $repo = Split-Path -Parent $PSScriptRoot if ([string]::IsNullOrWhiteSpace($BackupDirectory)) { $workspace = Split-Path -Parent $repo $BackupDirectory = Join-Path $workspace 'vignette-backups' } function Invoke-DockerChecked([string[]]$Arguments, [string]$FailureMessage) { & docker @Arguments if ($LASTEXITCODE -ne 0) { throw ("{0} (exit={1})" -f $FailureMessage, $LASTEXITCODE) } } function Get-ContainerEnvValue([string]$Key, [string]$Fallback) { $envLines = @(& docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' $ContainerName 2>$null) if ($LASTEXITCODE -ne 0) { return $Fallback } $prefix = "${Key}=" foreach ($line in $envLines) { $text = "$line" if ($text.StartsWith($prefix, [StringComparison]::Ordinal)) { return $text.Substring($prefix.Length) } } return $Fallback } if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw 'docker CLI를 찾을 수 없어 DB 백업을 시작하지 않았다.' } $containerState = & docker inspect -f '{{.State.Status}}' $ContainerName 2>$null if ($LASTEXITCODE -ne 0 -or "$containerState".Trim() -ne 'running') { throw "DB container '$ContainerName'이 running 상태가 아니어서 백업을 시작하지 않았다." } if ([string]::IsNullOrWhiteSpace($DatabaseName)) { $DatabaseName = Get-ContainerEnvValue 'POSTGRES_DB' 'vignette' } if ([string]::IsNullOrWhiteSpace($DatabaseUser)) { $DatabaseUser = Get-ContainerEnvValue 'POSTGRES_USER' 'vignette_owner' } Invoke-DockerChecked @('exec', $ContainerName, 'pg_isready', '-U', $DatabaseUser, '-d', $DatabaseName) ` 'PostgreSQL readiness 확인 실패. 불완전한 백업을 만들지 않았다.' | Out-Null New-Item -ItemType Directory -Force -Path $BackupDirectory | Out-Null $resolvedBackupDirectory = (Resolve-Path -LiteralPath $BackupDirectory).Path $timestamp = (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss') $safeContainer = $ContainerName -replace '[^A-Za-z0-9_.-]', '_' $fileName = "${safeContainer}-${DatabaseName}-${timestamp}Z.dump" $finalPath = Join-Path $resolvedBackupDirectory $fileName $partialPath = "${finalPath}.partial" $manifestPath = "${finalPath}.json" $manifestPartialPath = "${manifestPath}.partial" $containerTempPath = "/tmp/${safeContainer}-${timestamp}-$PID.dump" if ((Test-Path -LiteralPath $finalPath) -or (Test-Path -LiteralPath $manifestPath)) { throw "동일 이름의 백업이 이미 존재한다: $finalPath" } $backupSucceeded = $false try { Invoke-DockerChecked @( 'exec', $ContainerName, 'pg_dump', '-U', $DatabaseUser, '-d', $DatabaseName, '--format=custom', '--no-owner', '--no-privileges', '--file', $containerTempPath ) 'pg_dump 실패. 기존 DB에는 변경을 가하지 않았다.' | Out-Null Invoke-DockerChecked @('exec', $ContainerName, 'pg_restore', '--list', $containerTempPath) ` 'pg_restore TOC 검증 실패. 백업을 게시하지 않았다.' | Out-Null Invoke-DockerChecked @('cp', "${ContainerName}:${containerTempPath}", $partialPath) ` '검증된 dump를 로컬 임시 파일로 복사하지 못했다.' | Out-Null $file = Get-Item -LiteralPath $partialPath if ($file.Length -le 0) { throw '로컬 dump 크기가 0이어서 백업을 게시하지 않았다.' } $sha256 = (Get-FileHash -LiteralPath $partialPath -Algorithm SHA256).Hash.ToLowerInvariant() Move-Item -LiteralPath $partialPath -Destination $finalPath $containerId = (& docker inspect -f '{{.Id}}' $ContainerName).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($containerId)) { throw '백업 후 container identity를 고정하지 못했다.' } $manifest = [ordered]@{ schema_version = 1 created_at_utc = (Get-Date).ToUniversalTime().ToString('o') container_name = $ContainerName container_id = $containerId database_name = $DatabaseName format = 'postgres-custom' dump_file = $fileName size_bytes = (Get-Item -LiteralPath $finalPath).Length sha256 = $sha256 verified_with = 'pg_restore --list' } $json = $manifest | ConvertTo-Json -Depth 3 [IO.File]::WriteAllText($manifestPartialPath, $json + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) Move-Item -LiteralPath $manifestPartialPath -Destination $manifestPath $backupSucceeded = $true Write-Output ("Backup : {0}" -f $finalPath) Write-Output ("SHA-256: {0}" -f $sha256) Write-Output ("Manifest: {0}" -f $manifestPath) } finally { & docker exec $ContainerName rm -f $containerTempPath 1>$null 2>$null if (-not $backupSucceeded) { if (Test-Path -LiteralPath $partialPath) { Remove-Item -LiteralPath $partialPath -Force } if (Test-Path -LiteralPath $manifestPartialPath) { Remove-Item -LiteralPath $manifestPartialPath -Force } } }