param( [string]$SourceDb = '', [int]$ProbePort = 18087, [string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd') ) $ErrorActionPreference = 'Stop' $projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path if ([string]::IsNullOrWhiteSpace($SourceDb)) { $SourceDb = Join-Path $projectRoot 'data\user_management.db' } $evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\rollback" $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 $stableDb = Join-Path $drillRoot 'user_management.stable.db' $stableConfig = Join-Path $drillRoot 'config.stable.yaml' $candidateConfig = Join-Path $drillRoot 'config.candidate.yaml' $serverExe = Join-Path $drillRoot 'server-rollback.exe' $stableInitialStdOut = Join-Path $drillRoot 'stable-initial.stdout.log' $stableInitialStdErr = Join-Path $drillRoot 'stable-initial.stderr.log' $candidateStdOut = Join-Path $drillRoot 'candidate.stdout.log' $candidateStdErr = Join-Path $drillRoot 'candidate.stderr.log' $stableRollbackStdOut = Join-Path $drillRoot 'stable-rollback.stdout.log' $stableRollbackStdErr = Join-Path $drillRoot 'stable-rollback.stderr.log' $reportPath = Join-Path $drillRoot 'ROLLBACK_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 Stop-TreeProcess { param( [Parameter(Mandatory = $false)]$Process ) if (-not $Process) { return } if (-not $Process.HasExited) { try { taskkill /PID $Process.Id /T /F *> $null } catch { Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue } } } function Build-Config { param( [Parameter(Mandatory = $true)][string]$TemplatePath, [Parameter(Mandatory = $true)][string]$OutputPath, [Parameter(Mandatory = $true)][string]$DbPath, [Parameter(Mandatory = $true)][int]$Port ) $content = Get-Content $TemplatePath -Raw $dbPathForYaml = ($DbPath -replace '\\', '/') $content = $content -replace '(?m)^ port: \d+$', " port: $Port" $content = [regex]::Replace( $content, '(?ms)(sqlite:\s*\r?\n\s*path:\s*).+?(\r?\n)', "`$1`"$dbPathForYaml`"`$2" ) Set-Content -Path $OutputPath -Value $content -Encoding UTF8 } function Build-BadCandidateConfig { param( [Parameter(Mandatory = $true)][string]$StableConfigPath, [Parameter(Mandatory = $true)][string]$OutputPath ) $content = Get-Content $StableConfigPath -Raw $content = [regex]::Replace( $content, '(?ms)(allowed_origins:\s*\r?\n)(?:\s*-\s*.+\r?\n)+', "`$1 - ""*""`r`n" ) Set-Content -Path $OutputPath -Value $content -Encoding UTF8 } if (-not (Test-Path $SourceDb)) { throw "source db not found: $SourceDb" } Copy-Item $SourceDb $stableDb -Force Build-Config ` -TemplatePath (Join-Path $projectRoot 'configs\config.yaml') ` -OutputPath $stableConfig ` -DbPath $stableDb ` -Port $ProbePort Build-BadCandidateConfig ` -StableConfigPath $stableConfig ` -OutputPath $candidateConfig 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 rollback server failed' } } finally { Pop-Location Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue } $previousConfigPath = $env:UMS_CONFIG_PATH $stableInitialProcess = $null $candidateProcess = $null $stableRollbackProcess = $null try { $env:UMS_CONFIG_PATH = $stableConfig Remove-Item $stableInitialStdOut, $stableInitialStdErr -Force -ErrorAction SilentlyContinue $stableInitialProcess = Start-Process ` -FilePath $serverExe ` -WorkingDirectory $projectRoot ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $stableInitialStdOut ` -RedirectStandardError $stableInitialStdErr Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health" -Label 'stable initial health endpoint' Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health/ready" -Label 'stable initial readiness endpoint' $stableInitialCapabilities = Invoke-RestMethod "http://127.0.0.1:$ProbePort/api/v1/auth/capabilities" -TimeoutSec 5 } finally { Stop-TreeProcess $stableInitialProcess } try { $env:UMS_CONFIG_PATH = $candidateConfig Remove-Item $candidateStdOut, $candidateStdErr -Force -ErrorAction SilentlyContinue $candidateProcess = Start-Process ` -FilePath $serverExe ` -WorkingDirectory $projectRoot ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $candidateStdOut ` -RedirectStandardError $candidateStdErr Start-Sleep -Seconds 3 $candidateHealthReady = Test-UrlReady -Url "http://127.0.0.1:$ProbePort/health" $candidateExited = $candidateProcess.HasExited $candidateStdErrText = if (Test-Path $candidateStdErr) { Get-Content $candidateStdErr -Raw } else { '' } $candidateStdOutText = if (Test-Path $candidateStdOut) { Get-Content $candidateStdOut -Raw } else { '' } } finally { Stop-TreeProcess $candidateProcess } if ($candidateHealthReady) { throw 'candidate release unexpectedly became healthy; rollback drill invalid' } if (-not $candidateExited) { throw 'candidate release did not exit after invalid release configuration' } if ($candidateStdErrText -notmatch 'cors\.allowed_origins cannot contain \* in release mode' -and $candidateStdOutText -notmatch 'cors\.allowed_origins cannot contain \* in release mode') { throw 'candidate release did not expose the expected release validation failure' } try { $env:UMS_CONFIG_PATH = $stableConfig Remove-Item $stableRollbackStdOut, $stableRollbackStdErr -Force -ErrorAction SilentlyContinue $stableRollbackProcess = Start-Process ` -FilePath $serverExe ` -WorkingDirectory $projectRoot ` -PassThru ` -WindowStyle Hidden ` -RedirectStandardOutput $stableRollbackStdOut ` -RedirectStandardError $stableRollbackStdErr Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health" -Label 'rollback health endpoint' Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health/ready" -Label 'rollback readiness endpoint' $stableRollbackCapabilities = Invoke-RestMethod "http://127.0.0.1:$ProbePort/api/v1/auth/capabilities" -TimeoutSec 5 } finally { Stop-TreeProcess $stableRollbackProcess if ([string]::IsNullOrWhiteSpace($previousConfigPath)) { Remove-Item Env:UMS_CONFIG_PATH -ErrorAction SilentlyContinue } else { $env:UMS_CONFIG_PATH = $previousConfigPath } } $reportLines = @( '# Rollback Drill', '', "- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')", "- Source DB: $SourceDb", "- Stable DB copy: $stableDb", "- Probe port: $ProbePort", '', '## Drill Result', '', '- Stable release started successfully before rollback gate evaluation.', '- Candidate release was rejected by release-mode runtime validation before becoming healthy.', '- Rollback to the previous stable config/artifact path completed successfully on the same probe port.', "- Candidate rejection evidence: $(if ($candidateStdErrText -match 'cors\.allowed_origins cannot contain \* in release mode') { 'stderr matched release validation failure' } elseif ($candidateStdOutText -match 'cors\.allowed_origins cannot contain \* in release mode') { 'stdout matched release validation failure' } else { 'missing' })", "- Stable capabilities before rollback: $(($stableInitialCapabilities.data | ConvertTo-Json -Compress))", "- Stable capabilities after rollback: $(($stableRollbackCapabilities.data | ConvertTo-Json -Compress))", '', '## Scope Note', '', '- This local drill validates rollback operational steps and health gates for the current artifact/config path.', '- It does not prove cross-version schema downgrade compatibility between distinct historical releases.', '', '## Evidence Files', '', "- $(Split-Path $stableConfig -Leaf)", "- $(Split-Path $candidateConfig -Leaf)", "- $(Split-Path $stableInitialStdOut -Leaf)", "- $(Split-Path $stableInitialStdErr -Leaf)", "- $(Split-Path $candidateStdOut -Leaf)", "- $(Split-Path $candidateStdErr -Leaf)", "- $(Split-Path $stableRollbackStdOut -Leaf)", "- $(Split-Path $stableRollbackStdErr -Leaf)", '' ) Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8 Get-Content $reportPath