param( [string]$SourceDb = 'D:\project\data\user_management.db', [int]$ProbePort = 18080, [string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd') ) $ErrorActionPreference = 'Stop' $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\backup-restore" $goCacheRoot = Join-Path $projectRoot '.cache' $goBuildCache = Join-Path $goCacheRoot 'go-build' $goModCache = Join-Path $goCacheRoot 'gomod' $goPath = Join-Path $goCacheRoot 'gopath' $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' $drillRoot = Join-Path $evidenceRoot $timestamp $backupDb = Join-Path $drillRoot 'user_management.backup.db' $restoredDb = Join-Path $drillRoot 'user_management.restored.db' $sourceSnapshot = Join-Path $drillRoot 'source-snapshot.json' $restoredSnapshot = Join-Path $drillRoot 'restored-snapshot.json' $tempConfig = Join-Path $drillRoot 'config.restore.yaml' $serverExe = Join-Path $drillRoot 'server-restore.exe' $serverStdOut = Join-Path $drillRoot 'server.stdout.log' $serverStdErr = Join-Path $drillRoot 'server.stderr.log' $reportPath = Join-Path $drillRoot 'BACKUP_RESTORE_DRILL.md' New-Item -ItemType Directory -Force $evidenceRoot, $drillRoot, $goBuildCache, $goModCache, $goPath | Out-Null function Test-UrlReady { param( [Parameter(Mandatory = $true)][string]$Url ) try { $response = Invoke-WebRequest $Url -UseBasicParsing -TimeoutSec 2 return $response.StatusCode -ge 200 -and $response.StatusCode -lt 500 } catch { return $false } } function Wait-UrlReady { param( [Parameter(Mandatory = $true)][string]$Url, [Parameter(Mandatory = $true)][string]$Label, [int]$RetryCount = 120, [int]$DelayMs = 500 ) for ($i = 0; $i -lt $RetryCount; $i++) { if (Test-UrlReady -Url $Url) { return } Start-Sleep -Milliseconds $DelayMs } throw "$Label did not become ready: $Url" } function Invoke-GoTool { param( [Parameter(Mandatory = $true)][string[]]$Arguments, [Parameter(Mandatory = $true)][string]$OutputPath ) Push-Location $projectRoot try { $env:GOCACHE = $goBuildCache $env:GOMODCACHE = $goModCache $env:GOPATH = $goPath $output = & go @Arguments 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { throw $output } Set-Content -Path $OutputPath -Value $output -Encoding UTF8 } finally { Pop-Location Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue } } function Build-RestoreConfig { param( [Parameter(Mandatory = $true)][string]$TemplatePath, [Parameter(Mandatory = $true)][string]$OutputPath, [Parameter(Mandatory = $true)][string]$RestoredDbPath, [Parameter(Mandatory = $true)][int]$Port ) $content = Get-Content $TemplatePath -Raw $dbPath = ($RestoredDbPath -replace '\\', '/') $content = $content -replace '(?m)^ port: \d+$', " port: $Port" $content = [regex]::Replace( $content, '(?ms)(sqlite:\s*\r?\n\s*path:\s*).+?(\r?\n)', "`$1`"$dbPath`"`$2" ) Set-Content -Path $OutputPath -Value $content -Encoding UTF8 } if (-not (Test-Path $SourceDb)) { throw "source db not found: $SourceDb" } Invoke-GoTool -Arguments @('run', '.\tools\sqlite_snapshot_check.go', '-db', $SourceDb, '-json') -OutputPath $sourceSnapshot Copy-Item $SourceDb $backupDb -Force Copy-Item $backupDb $restoredDb -Force $sourceHash = (Get-FileHash $SourceDb -Algorithm SHA256).Hash $backupHash = (Get-FileHash $backupDb -Algorithm SHA256).Hash $restoredHash = (Get-FileHash $restoredDb -Algorithm SHA256).Hash Invoke-GoTool -Arguments @('run', '.\tools\sqlite_snapshot_check.go', '-db', $restoredDb, '-json') -OutputPath $restoredSnapshot $sourceSnapshotObject = Get-Content $sourceSnapshot -Raw | ConvertFrom-Json $restoredSnapshotObject = Get-Content $restoredSnapshot -Raw | ConvertFrom-Json if ($sourceHash -ne $backupHash -or $backupHash -ne $restoredHash) { throw 'backup/restore hash mismatch' } $sourceTablesJson = ($sourceSnapshotObject.Tables | ConvertTo-Json -Compress) $restoredTablesJson = ($restoredSnapshotObject.Tables | ConvertTo-Json -Compress) $sourceExistingTables = @($sourceSnapshotObject.existing_tables) $restoredExistingTables = @($restoredSnapshotObject.existing_tables) $sourceMissingTables = @($sourceSnapshotObject.missing_tables) $restoredMissingTables = @($restoredSnapshotObject.missing_tables) if ($sourceTablesJson -ne $restoredTablesJson) { throw "restored table counts mismatch: source=$sourceTablesJson restored=$restoredTablesJson" } if (($sourceExistingTables -join ',') -ne ($restoredExistingTables -join ',')) { throw "restored existing table set mismatch: source=$($sourceExistingTables -join ',') restored=$($restoredExistingTables -join ',')" } if (($sourceMissingTables -join ',') -ne ($restoredMissingTables -join ',')) { throw "restored missing table set mismatch: source=$($sourceMissingTables -join ',') restored=$($restoredMissingTables -join ',')" } Build-RestoreConfig ` -TemplatePath (Join-Path $projectRoot 'configs\config.yaml') ` -OutputPath $tempConfig ` -RestoredDbPath $restoredDb ` -Port $ProbePort Push-Location $projectRoot try { $env:GOCACHE = $goBuildCache $env:GOMODCACHE = $goModCache $env:GOPATH = $goPath & go build -o $serverExe .\cmd\server if ($LASTEXITCODE -ne 0) { throw 'build restore server failed' } } finally { Pop-Location Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue } $previousConfigPath = $env:UMS_CONFIG_PATH $env:UMS_CONFIG_PATH = $tempConfig $serverProcess = $null try { Remove-Item $serverStdOut, $serverStdErr -Force -ErrorAction SilentlyContinue $serverProcess = Start-Process ` -FilePath $serverExe ` -WorkingDirectory $projectRoot ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $serverStdOut ` -RedirectStandardError $serverStdErr Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health" -Label 'restore health endpoint' Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health/ready" -Label 'restore readiness endpoint' $capabilitiesResponse = Invoke-RestMethod "http://127.0.0.1:$ProbePort/api/v1/auth/capabilities" -TimeoutSec 5 } finally { if ($serverProcess -and -not $serverProcess.HasExited) { Stop-Process -Id $serverProcess.Id -Force -ErrorAction SilentlyContinue } if ([string]::IsNullOrWhiteSpace($previousConfigPath)) { Remove-Item Env:UMS_CONFIG_PATH -ErrorAction SilentlyContinue } else { $env:UMS_CONFIG_PATH = $previousConfigPath } } $sourceMissingSummary = if ($sourceMissingTables.Count -gt 0) { $sourceMissingTables -join ', ' } else { 'none' } $restoredMissingSummary = if ($restoredMissingTables.Count -gt 0) { $restoredMissingTables -join ', ' } else { 'none' } $sampleUsersSummary = @($sourceSnapshotObject.sample_users) -join ', ' $capabilitiesJson = ($capabilitiesResponse.data | ConvertTo-Json -Compress) $sourceSnapshotName = Split-Path $sourceSnapshot -Leaf $restoredSnapshotName = Split-Path $restoredSnapshot -Leaf $serverStdOutName = Split-Path $serverStdOut -Leaf $serverStdErrName = Split-Path $serverStdErr -Leaf $tempConfigName = Split-Path $tempConfig -Leaf $reportLines = @( '# Backup Restore Drill', '', "- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')", "- Source DB: $SourceDb", "- Backup DB: $backupDb", "- Restored DB: $restoredDb", "- Probe port: $ProbePort", '', '## Hash Validation', '', "- source sha256: $sourceHash", "- backup sha256: $backupHash", "- restored sha256: $restoredHash", '', '## Snapshot Comparison', '', "- source tables: $sourceTablesJson", "- restored tables: $restoredTablesJson", "- source existing tables: $($sourceExistingTables -join ', ')", "- restored existing tables: $($restoredExistingTables -join ', ')", "- source missing tables: $sourceMissingSummary", "- restored missing tables: $restoredMissingSummary", "- sample users: $sampleUsersSummary", '', '## Restore Service Verification', '', '- GET /health: pass', '- GET /health/ready: pass', '- GET /api/v1/auth/capabilities: pass', "- auth capabilities payload: $capabilitiesJson", '', '## Evidence Files', '', "- $sourceSnapshotName", "- $restoredSnapshotName", "- $serverStdOutName", "- $serverStdErrName", "- $tempConfigName", '' ) Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8 Get-Content $reportPath