260 lines
9.0 KiB
PowerShell
260 lines
9.0 KiB
PowerShell
|
|
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
|