docs: project docs, scripts, deployment configs, and evidence
This commit is contained in:
35
scripts/dev/init-admin-local.ps1
Normal file
35
scripts/dev/init-admin-local.ps1
Normal file
@@ -0,0 +1,35 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Username,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Password,
|
||||
|
||||
[string]$Email = '',
|
||||
|
||||
[switch]$ResetPassword
|
||||
)
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$cacheRoot = Join-Path $repoRoot '.cache\go'
|
||||
$buildCache = Join-Path $repoRoot '.cache\go-build'
|
||||
$modCache = Join-Path $cacheRoot 'pkg\mod'
|
||||
|
||||
New-Item -ItemType Directory -Force $cacheRoot, $buildCache, $modCache | Out-Null
|
||||
|
||||
$env:GOPATH = $cacheRoot
|
||||
$env:GOCACHE = $buildCache
|
||||
$env:GOMODCACHE = $modCache
|
||||
$env:UMS_ADMIN_USERNAME = $Username
|
||||
$env:UMS_ADMIN_PASSWORD = $Password
|
||||
$env:UMS_ADMIN_EMAIL = $Email
|
||||
$env:UMS_ADMIN_RESET_PASSWORD = if ($ResetPassword.IsPresent) { 'true' } else { 'false' }
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
& go run .\tools\init_admin.go
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
135
scripts/dev/start-preview-local.ps1
Normal file
135
scripts/dev/start-preview-local.ps1
Normal file
@@ -0,0 +1,135 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Stop-TrackedProcess {
|
||||
param(
|
||||
[string]$PidFile
|
||||
)
|
||||
|
||||
if (-not (Test-Path $PidFile)) {
|
||||
return
|
||||
}
|
||||
|
||||
$pidText = (Get-Content $PidFile -Raw).Trim()
|
||||
if (-not $pidText) {
|
||||
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
||||
return
|
||||
}
|
||||
|
||||
$existing = Get-Process -Id ([int]$pidText) -ErrorAction SilentlyContinue
|
||||
if ($null -ne $existing) {
|
||||
Stop-Process -Id $existing.Id -Force
|
||||
}
|
||||
|
||||
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Wait-HttpReady {
|
||||
param(
|
||||
[string]$Url,
|
||||
[int]$MaxAttempts = 60,
|
||||
[int]$SleepSeconds = 1
|
||||
)
|
||||
|
||||
for ($i = 0; $i -lt $MaxAttempts; $i++) {
|
||||
try {
|
||||
$response = Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 3
|
||||
return $response
|
||||
} catch {
|
||||
Start-Sleep -Seconds $SleepSeconds
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Show-LogTail {
|
||||
param(
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if (Test-Path $Path) {
|
||||
Write-Host ""
|
||||
Write-Host "Last log lines: $Path"
|
||||
Get-Content $Path -Tail 40
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$frontendRoot = Join-Path $repoRoot 'frontend\admin'
|
||||
$runtimeRoot = Join-Path $repoRoot 'runtime'
|
||||
$logsRoot = Join-Path $repoRoot 'logs'
|
||||
$binRoot = Join-Path $repoRoot 'bin'
|
||||
$cacheRoot = Join-Path $repoRoot '.cache\go'
|
||||
$buildCache = Join-Path $repoRoot '.cache\go-build'
|
||||
$modCache = Join-Path $cacheRoot 'pkg\mod'
|
||||
|
||||
New-Item -ItemType Directory -Force $runtimeRoot, $logsRoot, $binRoot, $cacheRoot, $buildCache, $modCache | Out-Null
|
||||
|
||||
$backendPidFile = Join-Path $runtimeRoot 'backend.pid'
|
||||
$frontendPidFile = Join-Path $runtimeRoot 'frontend.pid'
|
||||
$backendOut = Join-Path $logsRoot 'backend-dev.out.log'
|
||||
$backendErr = Join-Path $logsRoot 'backend-dev.err.log'
|
||||
$frontendOut = Join-Path $logsRoot 'frontend-dev.out.log'
|
||||
$frontendErr = Join-Path $logsRoot 'frontend-dev.err.log'
|
||||
$backendExe = Join-Path $binRoot 'server.exe'
|
||||
|
||||
Stop-TrackedProcess -PidFile $backendPidFile
|
||||
Stop-TrackedProcess -PidFile $frontendPidFile
|
||||
|
||||
Remove-Item $backendOut, $backendErr, $frontendOut, $frontendErr -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$env:GOPATH = $cacheRoot
|
||||
$env:GOCACHE = $buildCache
|
||||
$env:GOMODCACHE = $modCache
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
& go build -o $backendExe .\cmd\server
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$node = (Get-Command node).Source
|
||||
|
||||
$backendProcess = Start-Process -FilePath $backendExe `
|
||||
-WorkingDirectory $repoRoot `
|
||||
-RedirectStandardOutput $backendOut `
|
||||
-RedirectStandardError $backendErr `
|
||||
-PassThru
|
||||
Set-Content -Path $backendPidFile -Value $backendProcess.Id
|
||||
|
||||
$frontendProcess = Start-Process -FilePath $node `
|
||||
-ArgumentList '.\node_modules\vite\bin\vite.js', '--configLoader', 'native', '--host', '0.0.0.0', '--port', '3000' `
|
||||
-WorkingDirectory $frontendRoot `
|
||||
-RedirectStandardOutput $frontendOut `
|
||||
-RedirectStandardError $frontendErr `
|
||||
-PassThru
|
||||
Set-Content -Path $frontendPidFile -Value $frontendProcess.Id
|
||||
|
||||
$backendReady = Wait-HttpReady -Url 'http://127.0.0.1:8080/health'
|
||||
$frontendReady = Wait-HttpReady -Url 'http://127.0.0.1:3000'
|
||||
|
||||
if ($null -eq $backendReady) {
|
||||
Write-Host 'Backend failed to become ready on http://127.0.0.1:8080/health'
|
||||
Show-LogTail -Path $backendErr
|
||||
Show-LogTail -Path $backendOut
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($null -eq $frontendReady) {
|
||||
Write-Host 'Frontend failed to become ready on http://127.0.0.1:3000'
|
||||
Show-LogTail -Path $frontendErr
|
||||
Show-LogTail -Path $frontendOut
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host "Backend ready: http://127.0.0.1:8080"
|
||||
Write-Host "Frontend ready: http://127.0.0.1:3000"
|
||||
Write-Host "Backend PID: $($backendProcess.Id)"
|
||||
Write-Host "Frontend PID: $($frontendProcess.Id)"
|
||||
Write-Host "Stop command: powershell -ExecutionPolicy Bypass -File scripts/dev/stop-preview-local.ps1"
|
||||
Write-Host ""
|
||||
Write-Host "Note: this repository does not ship a default admin account."
|
||||
Write-Host "If login fails on first run, initialize one explicitly:"
|
||||
Write-Host " powershell -ExecutionPolicy Bypass -File scripts/dev/init-admin-local.ps1 -Username admin -Password '<strong-password>' -Email 'admin@example.com'"
|
||||
31
scripts/dev/stop-preview-local.ps1
Normal file
31
scripts/dev/stop-preview-local.ps1
Normal file
@@ -0,0 +1,31 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Stop-TrackedProcess {
|
||||
param(
|
||||
[string]$PidFile
|
||||
)
|
||||
|
||||
if (-not (Test-Path $PidFile)) {
|
||||
return
|
||||
}
|
||||
|
||||
$pidText = (Get-Content $PidFile -Raw).Trim()
|
||||
if (-not $pidText) {
|
||||
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
||||
return
|
||||
}
|
||||
|
||||
$existing = Get-Process -Id ([int]$pidText) -ErrorAction SilentlyContinue
|
||||
if ($null -ne $existing) {
|
||||
Stop-Process -Id $existing.Id -Force
|
||||
Write-Host "Stopped PID $($existing.Id)"
|
||||
}
|
||||
|
||||
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$runtimeRoot = Join-Path $repoRoot 'runtime'
|
||||
|
||||
Stop-TrackedProcess -PidFile (Join-Path $runtimeRoot 'backend.pid')
|
||||
Stop-TrackedProcess -PidFile (Join-Path $runtimeRoot 'frontend.pid')
|
||||
172
scripts/ops/capture-local-baseline.ps1
Normal file
172
scripts/ops/capture-local-baseline.ps1
Normal file
@@ -0,0 +1,172 @@
|
||||
param(
|
||||
[string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$frontendRoot = Join-Path $projectRoot 'frontend\admin'
|
||||
$evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\observability"
|
||||
$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'
|
||||
$goOutputPath = Join-Path $evidenceRoot "concurrent-login-$timestamp.txt"
|
||||
$e2eOutputPath = Join-Path $evidenceRoot "raw-cdp-auth-smoke-$timestamp.txt"
|
||||
$summaryPath = Join-Path $evidenceRoot "LOCAL_BASELINE_$timestamp.md"
|
||||
|
||||
New-Item -ItemType Directory -Force $evidenceRoot, $goBuildCache, $goModCache, $goPath | Out-Null
|
||||
|
||||
function Invoke-CapturedCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[string[]]$ArgumentList = @(),
|
||||
[Parameter(Mandatory = $true)][string]$WorkingDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$StdOutPath,
|
||||
[int]$TimeoutSec = 600
|
||||
)
|
||||
|
||||
$stdErrPath = "$StdOutPath.stderr.txt"
|
||||
Remove-Item $StdOutPath, $stdErrPath -Force -ErrorAction SilentlyContinue
|
||||
$process = Start-Process `
|
||||
-FilePath $FilePath `
|
||||
-ArgumentList $ArgumentList `
|
||||
-WorkingDirectory $WorkingDirectory `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $StdOutPath `
|
||||
-RedirectStandardError $stdErrPath
|
||||
|
||||
if (-not $process.WaitForExit($TimeoutSec * 1000)) {
|
||||
try {
|
||||
taskkill /PID $process.Id /T /F *> $null
|
||||
} catch {
|
||||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
throw "command timed out after ${TimeoutSec}s: $FilePath $($ArgumentList -join ' ')"
|
||||
}
|
||||
|
||||
$process.WaitForExit()
|
||||
$exitCode = $process.ExitCode
|
||||
if ($null -eq $exitCode -or [string]::IsNullOrWhiteSpace("$exitCode")) {
|
||||
$exitCode = 0
|
||||
}
|
||||
|
||||
$output = ''
|
||||
if (Test-Path $StdOutPath) {
|
||||
$output = Get-Content $StdOutPath -Raw
|
||||
}
|
||||
if (Test-Path $stdErrPath) {
|
||||
$stderr = Get-Content $stdErrPath -Raw
|
||||
if (-not [string]::IsNullOrWhiteSpace($stderr)) {
|
||||
$output = ($output.TrimEnd() + [Environment]::NewLine + $stderr.Trim()).Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
ExitCode = $exitCode
|
||||
Output = $output
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ConcurrentSummary {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Output
|
||||
)
|
||||
|
||||
if ($Output -match '(?s)map\[(?<status>[^\]]+)\].*?(?<total>[0-9.]+[a-zA-Z]+).*?(?<avg>[0-9.]+[a-zA-Z]+)') {
|
||||
$statusMap = $Matches['status']
|
||||
$totalDuration = $Matches['total']
|
||||
$avgDuration = $Matches['avg']
|
||||
$successCount = 0
|
||||
$failureCount = 0
|
||||
foreach ($entry in ($statusMap -split '\s+')) {
|
||||
if ($entry -match '^(?<code>\d+):(?<count>\d+)$') {
|
||||
$count = [int]$Matches['count']
|
||||
if ($Matches['code'] -eq '200') {
|
||||
$successCount += $count
|
||||
} else {
|
||||
$failureCount += $count
|
||||
}
|
||||
}
|
||||
}
|
||||
return "success=$successCount fail=$failureCount status=map[$statusMap] total=$totalDuration avg=$avgDuration"
|
||||
}
|
||||
|
||||
return 'unavailable'
|
||||
}
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
$env:GOCACHE = $goBuildCache
|
||||
$env:GOMODCACHE = $goModCache
|
||||
$env:GOPATH = $goPath
|
||||
$goResult = Invoke-CapturedCommand `
|
||||
-FilePath 'go' `
|
||||
-ArgumentList @('test', './internal/e2e', '-run', 'TestE2EConcurrentLogin', '-v', '-count=1') `
|
||||
-WorkingDirectory $projectRoot `
|
||||
-StdOutPath $goOutputPath `
|
||||
-TimeoutSec 300
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$e2eResult = Invoke-CapturedCommand `
|
||||
-FilePath 'npm.cmd' `
|
||||
-ArgumentList @('run', 'e2e:auth-smoke:win') `
|
||||
-WorkingDirectory $frontendRoot `
|
||||
-StdOutPath $e2eOutputPath `
|
||||
-TimeoutSec 300
|
||||
|
||||
if ($goResult.ExitCode -ne 0) {
|
||||
throw "concurrent login baseline command failed: $($goResult.ExitCode)"
|
||||
}
|
||||
if ($e2eResult.ExitCode -ne 0) {
|
||||
throw "raw cdp baseline command failed: $($e2eResult.ExitCode)"
|
||||
}
|
||||
if ($goResult.Output -notmatch '(?m)^PASS$' -or $goResult.Output -notmatch '(?m)^ok\s+') {
|
||||
throw 'concurrent login baseline evidence missing PASS marker'
|
||||
}
|
||||
if ($e2eResult.Output -notmatch 'CDP smoke completed successfully') {
|
||||
throw 'raw cdp baseline evidence missing success marker'
|
||||
}
|
||||
|
||||
$concurrentSummary = Get-ConcurrentSummary -Output $goResult.Output
|
||||
$loginInitial = ([regex]::Match($e2eResult.Output, 'login-initial:\s*([0-9]+ms)')).Groups[1].Value
|
||||
$loginDesktop = ([regex]::Match($e2eResult.Output, 'login-desktop:\s*([0-9]+ms)')).Groups[1].Value
|
||||
$loginTablet = ([regex]::Match($e2eResult.Output, 'login-tablet:\s*([0-9]+ms)')).Groups[1].Value
|
||||
$loginMobile = ([regex]::Match($e2eResult.Output, 'login-mobile:\s*([0-9]+ms)')).Groups[1].Value
|
||||
|
||||
$summaryLines = @(
|
||||
'# Local Observability Baseline',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
'- Scope: single-node local baseline, not a production traffic certification result',
|
||||
'',
|
||||
'## Concurrent Login Baseline',
|
||||
'',
|
||||
'- Source command: `go test ./internal/e2e -run TestE2EConcurrentLogin -v -count=1`',
|
||||
'- Concurrency configured by test: 20',
|
||||
"- Result: $concurrentSummary",
|
||||
'- Interpretation: current login rate limiter absorbs most burst traffic with 429, while successful requests remained sub-second and no 5xx appeared.',
|
||||
'',
|
||||
'## Browser Flow Baseline',
|
||||
'',
|
||||
'- Source command: `cd frontend/admin && npm.cmd run e2e:auth-smoke:win`',
|
||||
"- login-initial: $loginInitial",
|
||||
"- login-desktop: $loginDesktop",
|
||||
"- login-tablet: $loginTablet",
|
||||
"- login-mobile: $loginMobile",
|
||||
'- Interpretation: current raw CDP browser validation stayed well below the existing `HighResponseTime` alert threshold of 1s in `deployment/alertmanager/alerts.yml`.',
|
||||
'',
|
||||
'## Evidence Files',
|
||||
'',
|
||||
"- $(Split-Path $goOutputPath -Leaf)",
|
||||
"- $(Split-Path $e2eOutputPath -Leaf)",
|
||||
''
|
||||
)
|
||||
|
||||
Set-Content -Path $summaryPath -Value ($summaryLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $summaryPath
|
||||
436
scripts/ops/drill-alertmanager-live-delivery.ps1
Normal file
436
scripts/ops/drill-alertmanager-live-delivery.ps1
Normal file
@@ -0,0 +1,436 @@
|
||||
param(
|
||||
[string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd'),
|
||||
[string]$EnvFilePath = '',
|
||||
[int]$TimeoutSeconds = 20,
|
||||
[switch]$DisableSsl
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\alerting"
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$drillRoot = Join-Path $evidenceRoot $timestamp
|
||||
$sanitizedConfigPath = Join-Path $drillRoot 'alertmanager.live.redacted.yaml'
|
||||
$reportPath = Join-Path $drillRoot 'ALERTMANAGER_LIVE_DELIVERY_DRILL.md'
|
||||
$tempRenderedPath = Join-Path ([System.IO.Path]::GetTempPath()) ("alertmanager-live-" + [System.Guid]::NewGuid().ToString('N') + '.yaml')
|
||||
|
||||
$requiredVariables = @(
|
||||
'ALERTMANAGER_DEFAULT_TO',
|
||||
'ALERTMANAGER_CRITICAL_TO',
|
||||
'ALERTMANAGER_WARNING_TO',
|
||||
'ALERTMANAGER_FROM',
|
||||
'ALERTMANAGER_SMARTHOST',
|
||||
'ALERTMANAGER_AUTH_USERNAME',
|
||||
'ALERTMANAGER_AUTH_PASSWORD'
|
||||
)
|
||||
|
||||
New-Item -ItemType Directory -Force $evidenceRoot, $drillRoot | Out-Null
|
||||
|
||||
function Import-EnvFileToProcess {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
$saved = @()
|
||||
foreach ($rawLine in Get-Content $Path -Encoding UTF8) {
|
||||
$line = $rawLine.Trim()
|
||||
if ($line -eq '' -or $line.StartsWith('#')) {
|
||||
continue
|
||||
}
|
||||
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -ne 2) {
|
||||
throw "invalid env line: $line"
|
||||
}
|
||||
|
||||
$name = $parts[0].Trim()
|
||||
$value = $parts[1].Trim()
|
||||
$existing = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
$saved += [pscustomobject]@{
|
||||
Name = $name
|
||||
HadValue = -not [string]::IsNullOrEmpty($existing)
|
||||
Value = $existing
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($name, $value, 'Process')
|
||||
}
|
||||
|
||||
return $saved
|
||||
}
|
||||
|
||||
function Restore-ProcessEnv {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][object[]]$SavedState
|
||||
)
|
||||
|
||||
foreach ($entry in $SavedState) {
|
||||
if ($entry.HadValue) {
|
||||
[Environment]::SetEnvironmentVariable($entry.Name, $entry.Value, 'Process')
|
||||
continue
|
||||
}
|
||||
|
||||
Remove-Item ("Env:" + $entry.Name) -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ConfiguredValues {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string[]]$Names
|
||||
)
|
||||
|
||||
$values = @{}
|
||||
foreach ($name in $Names) {
|
||||
$values[$name] = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
}
|
||||
return $values
|
||||
}
|
||||
|
||||
function Get-PlaceholderFindings {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][hashtable]$Values
|
||||
)
|
||||
|
||||
$findings = @()
|
||||
foreach ($entry in $Values.GetEnumerator()) {
|
||||
$name = $entry.Key
|
||||
$value = [string]$entry.Value
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($value -match '\$\{[A-Z0-9_]+\}') {
|
||||
$findings += "$name contains unresolved placeholder syntax"
|
||||
}
|
||||
|
||||
if ($value -match '(?i)\bexample\.(com|org)\b') {
|
||||
$findings += "$name still uses example domain"
|
||||
}
|
||||
|
||||
if ($name -like '*PASSWORD' -and $value -match '(?i)^(replace-with-secret|synthetic-secret-for-render-drill|password)$') {
|
||||
$findings += "$name still uses placeholder secret"
|
||||
}
|
||||
}
|
||||
|
||||
return $findings
|
||||
}
|
||||
|
||||
function Parse-Smarthost {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Value
|
||||
)
|
||||
|
||||
$match = [regex]::Match($Value, '^(?<host>\[[^\]]+\]|[^:]+)(:(?<port>\d+))?$')
|
||||
if (-not $match.Success) {
|
||||
throw "invalid ALERTMANAGER_SMARTHOST value: $Value"
|
||||
}
|
||||
|
||||
$host = $match.Groups['host'].Value.Trim('[', ']')
|
||||
$port = if ($match.Groups['port'].Success) { [int]$match.Groups['port'].Value } else { 25 }
|
||||
|
||||
return [pscustomobject]@{
|
||||
Host = $host
|
||||
Port = $port
|
||||
Raw = $Value
|
||||
}
|
||||
}
|
||||
|
||||
function Split-Recipients {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Value
|
||||
)
|
||||
|
||||
return @(
|
||||
$Value -split '[,;]' |
|
||||
ForEach-Object { $_.Trim() } |
|
||||
Where-Object { $_ -ne '' }
|
||||
)
|
||||
}
|
||||
|
||||
function Mask-EmailList {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Value
|
||||
)
|
||||
|
||||
$masked = @()
|
||||
foreach ($recipient in Split-Recipients -Value $Value) {
|
||||
if ($recipient -notmatch '^(?<local>[^@]+)@(?<domain>.+)$') {
|
||||
$masked += '***REDACTED***'
|
||||
continue
|
||||
}
|
||||
|
||||
$local = $Matches['local']
|
||||
$domain = $Matches['domain']
|
||||
$prefix = if ($local.Length -gt 0) { $local.Substring(0, 1) } else { '*' }
|
||||
$masked += ($prefix + '***@' + $domain)
|
||||
}
|
||||
|
||||
return $masked -join ', '
|
||||
}
|
||||
|
||||
function Mask-Host {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Value
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return '***REDACTED_HOST***'
|
||||
}
|
||||
|
||||
if ($Value.Length -le 3) {
|
||||
return ($Value.Substring(0, 1) + '**')
|
||||
}
|
||||
|
||||
return ($Value.Substring(0, 1) + '***' + $Value.Substring($Value.Length - 2))
|
||||
}
|
||||
|
||||
function Test-TcpConnectivity {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Host,
|
||||
[Parameter(Mandatory = $true)][int]$Port,
|
||||
[Parameter(Mandatory = $true)][int]$TimeoutSeconds
|
||||
)
|
||||
|
||||
$client = New-Object System.Net.Sockets.TcpClient
|
||||
try {
|
||||
$asyncResult = $client.BeginConnect($Host, $Port, $null, $null)
|
||||
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000, $false)) {
|
||||
throw "tcp connect timeout after ${TimeoutSeconds}s"
|
||||
}
|
||||
|
||||
$client.EndConnect($asyncResult)
|
||||
return [pscustomobject]@{
|
||||
Succeeded = $true
|
||||
Error = ''
|
||||
}
|
||||
} catch {
|
||||
return [pscustomobject]@{
|
||||
Succeeded = $false
|
||||
Error = $_.Exception.Message
|
||||
}
|
||||
} finally {
|
||||
$client.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Send-SmtpMessage {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][pscustomobject]$Smarthost,
|
||||
[Parameter(Mandatory = $true)][string]$From,
|
||||
[Parameter(Mandatory = $true)][string]$To,
|
||||
[Parameter(Mandatory = $true)][string]$Username,
|
||||
[Parameter(Mandatory = $true)][string]$Password,
|
||||
[Parameter(Mandatory = $true)][string]$RouteName,
|
||||
[Parameter(Mandatory = $true)][int]$TimeoutSeconds,
|
||||
[Parameter(Mandatory = $true)][bool]$EnableSsl
|
||||
)
|
||||
|
||||
$message = [System.Net.Mail.MailMessage]::new()
|
||||
$smtp = [System.Net.Mail.SmtpClient]::new($Smarthost.Host, $Smarthost.Port)
|
||||
|
||||
try {
|
||||
$message.From = [System.Net.Mail.MailAddress]::new($From)
|
||||
foreach ($recipient in Split-Recipients -Value $To) {
|
||||
$message.To.Add($recipient)
|
||||
}
|
||||
|
||||
$message.Subject = "[ALERTING-LIVE-DRILL][$RouteName] $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')"
|
||||
$message.Body = @"
|
||||
This is a live alert delivery drill.
|
||||
Route: $RouteName
|
||||
Project: $projectRoot
|
||||
GeneratedAt: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')
|
||||
"@
|
||||
|
||||
$smtp.EnableSsl = $EnableSsl
|
||||
$smtp.Timeout = $TimeoutSeconds * 1000
|
||||
$smtp.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::Network
|
||||
$smtp.UseDefaultCredentials = $false
|
||||
$smtp.Credentials = [System.Net.NetworkCredential]::new($Username, $Password)
|
||||
$smtp.Send($message)
|
||||
|
||||
return [pscustomobject]@{
|
||||
Route = $RouteName
|
||||
RecipientMask = Mask-EmailList -Value $To
|
||||
Accepted = $true
|
||||
Error = ''
|
||||
}
|
||||
} catch {
|
||||
return [pscustomobject]@{
|
||||
Route = $RouteName
|
||||
RecipientMask = Mask-EmailList -Value $To
|
||||
Accepted = $false
|
||||
Error = $_.Exception.Message
|
||||
}
|
||||
} finally {
|
||||
$message.Dispose()
|
||||
$smtp.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RedactedRenderedConfig {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RenderedContent,
|
||||
[Parameter(Mandatory = $true)][hashtable]$Values
|
||||
)
|
||||
|
||||
$redacted = $RenderedContent
|
||||
$replacementMap = @{
|
||||
'ALERTMANAGER_DEFAULT_TO' = '***REDACTED_DEFAULT_TO***'
|
||||
'ALERTMANAGER_CRITICAL_TO' = '***REDACTED_CRITICAL_TO***'
|
||||
'ALERTMANAGER_WARNING_TO' = '***REDACTED_WARNING_TO***'
|
||||
'ALERTMANAGER_FROM' = '***REDACTED_FROM***'
|
||||
'ALERTMANAGER_SMARTHOST' = '***REDACTED_SMARTHOST***'
|
||||
'ALERTMANAGER_AUTH_USERNAME' = '***REDACTED_AUTH_USERNAME***'
|
||||
'ALERTMANAGER_AUTH_PASSWORD' = '***REDACTED_AUTH_PASSWORD***'
|
||||
}
|
||||
|
||||
foreach ($entry in $replacementMap.GetEnumerator()) {
|
||||
$value = [string]$Values[$entry.Key]
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$redacted = [regex]::Replace($redacted, [regex]::Escape($value), [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $entry.Value })
|
||||
}
|
||||
|
||||
return $redacted
|
||||
}
|
||||
|
||||
$savedEnvState = @()
|
||||
$values = @{}
|
||||
$missingVariables = @()
|
||||
$placeholderFindings = @()
|
||||
$renderSucceeded = $false
|
||||
$tcpResult = [pscustomobject]@{ Succeeded = $false; Error = 'not-run' }
|
||||
$sendResults = @()
|
||||
$success = $false
|
||||
$failureReason = ''
|
||||
$smarthost = $null
|
||||
$envSource = if ([string]::IsNullOrWhiteSpace($EnvFilePath)) { 'process environment' } else { $EnvFilePath }
|
||||
|
||||
try {
|
||||
if (-not [string]::IsNullOrWhiteSpace($EnvFilePath)) {
|
||||
if (-not (Test-Path $EnvFilePath)) {
|
||||
throw "env file not found: $EnvFilePath"
|
||||
}
|
||||
|
||||
$savedEnvState = Import-EnvFileToProcess -Path $EnvFilePath
|
||||
}
|
||||
|
||||
$values = Get-ConfiguredValues -Names $requiredVariables
|
||||
$missingVariables = @(
|
||||
$requiredVariables |
|
||||
Where-Object { [string]::IsNullOrWhiteSpace([string]$values[$_]) }
|
||||
)
|
||||
$placeholderFindings = Get-PlaceholderFindings -Values $values
|
||||
|
||||
if ($missingVariables.Count -gt 0) {
|
||||
throw "missing required alertmanager variables: $($missingVariables -join ', ')"
|
||||
}
|
||||
|
||||
if ($placeholderFindings.Count -gt 0) {
|
||||
throw "placeholder or example values detected"
|
||||
}
|
||||
|
||||
$smarthost = Parse-Smarthost -Value ([string]$values['ALERTMANAGER_SMARTHOST'])
|
||||
|
||||
& (Join-Path $PSScriptRoot 'render-alertmanager-config.ps1') `
|
||||
-TemplatePath (Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml') `
|
||||
-OutputPath $tempRenderedPath `
|
||||
-EnvFilePath $EnvFilePath | Out-Null
|
||||
|
||||
$renderedContent = Get-Content $tempRenderedPath -Raw -Encoding UTF8
|
||||
$redactedContent = Get-RedactedRenderedConfig -RenderedContent $renderedContent -Values $values
|
||||
Set-Content -Path $sanitizedConfigPath -Value $redactedContent -Encoding UTF8
|
||||
$renderSucceeded = $true
|
||||
|
||||
$tcpResult = Test-TcpConnectivity -Host $smarthost.Host -Port $smarthost.Port -TimeoutSeconds $TimeoutSeconds
|
||||
if (-not $tcpResult.Succeeded) {
|
||||
throw "smtp tcp connectivity failed: $($tcpResult.Error)"
|
||||
}
|
||||
|
||||
$routes = @(
|
||||
[pscustomobject]@{ Name = 'default'; To = [string]$values['ALERTMANAGER_DEFAULT_TO'] }
|
||||
[pscustomobject]@{ Name = 'critical-alerts'; To = [string]$values['ALERTMANAGER_CRITICAL_TO'] }
|
||||
[pscustomobject]@{ Name = 'warning-alerts'; To = [string]$values['ALERTMANAGER_WARNING_TO'] }
|
||||
)
|
||||
|
||||
foreach ($route in $routes) {
|
||||
$sendResults += Send-SmtpMessage `
|
||||
-Smarthost $smarthost `
|
||||
-From ([string]$values['ALERTMANAGER_FROM']) `
|
||||
-To $route.To `
|
||||
-Username ([string]$values['ALERTMANAGER_AUTH_USERNAME']) `
|
||||
-Password ([string]$values['ALERTMANAGER_AUTH_PASSWORD']) `
|
||||
-RouteName $route.Name `
|
||||
-TimeoutSeconds $TimeoutSeconds `
|
||||
-EnableSsl (-not $DisableSsl.IsPresent)
|
||||
}
|
||||
|
||||
$failedRoutes = @($sendResults | Where-Object { -not $_.Accepted })
|
||||
if ($failedRoutes.Count -gt 0) {
|
||||
throw "smtp send failed for route(s): $($failedRoutes.Route -join ', ')"
|
||||
}
|
||||
|
||||
$success = $true
|
||||
} catch {
|
||||
$failureReason = $_.Exception.Message
|
||||
} finally {
|
||||
if (Test-Path $tempRenderedPath) {
|
||||
Remove-Item $tempRenderedPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($savedEnvState.Count -gt 0) {
|
||||
Restore-ProcessEnv -SavedState $savedEnvState
|
||||
}
|
||||
|
||||
$reportLines = @(
|
||||
'# Alertmanager Live Delivery Drill',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
"- Template file: $(Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml')",
|
||||
"- Env source: $envSource",
|
||||
"- Redacted rendered config: $(if (Test-Path $sanitizedConfigPath) { $sanitizedConfigPath } else { 'not-generated' })",
|
||||
'',
|
||||
'## Strict Preconditions',
|
||||
'',
|
||||
"- Required variables present: $($missingVariables.Count -eq 0)",
|
||||
"- Placeholder/example-value findings: $(if ($placeholderFindings.Count -gt 0) { $placeholderFindings -join '; ' } else { 'none' })",
|
||||
"- Render path succeeded: $renderSucceeded",
|
||||
'',
|
||||
'## Delivery Attempt',
|
||||
'',
|
||||
"- SMTP host: $(if ($smarthost) { (Mask-Host -Value $smarthost.Host) } else { 'unparsed' })",
|
||||
"- SMTP port: $(if ($smarthost) { $smarthost.Port } else { 'unparsed' })",
|
||||
"- TLS enabled: $(-not $DisableSsl.IsPresent)",
|
||||
"- TCP connectivity succeeded: $($tcpResult.Succeeded)",
|
||||
"- TCP connectivity error: $(if ($tcpResult.Error) { $tcpResult.Error } else { 'none' })",
|
||||
''
|
||||
)
|
||||
|
||||
if ($sendResults.Count -gt 0) {
|
||||
$reportLines += '## Route Results'
|
||||
$reportLines += ''
|
||||
foreach ($result in $sendResults) {
|
||||
$reportLines += "- Route $($result.Route): accepted=$($result.Accepted), recipients=$($result.RecipientMask), error=$(if ([string]::IsNullOrWhiteSpace($result.Error)) { 'none' } else { $result.Error })"
|
||||
}
|
||||
$reportLines += ''
|
||||
}
|
||||
|
||||
$reportLines += '## Conclusion'
|
||||
$reportLines += ''
|
||||
$reportLines += "- Live external delivery closed: $success"
|
||||
$reportLines += "- Failure reason: $(if ([string]::IsNullOrWhiteSpace($failureReason)) { 'none' } else { $failureReason })"
|
||||
$reportLines += '- This drill fails closed on unresolved placeholders, example domains, and placeholder secrets.'
|
||||
$reportLines += '- The evidence intentionally stores only redacted config output and masked recipient information.'
|
||||
$reportLines += '- A successful run proves real secret injection plus SMTP server acceptance for the configured on-call routes; it does not by itself prove downstream human acknowledgment.'
|
||||
$reportLines += ''
|
||||
|
||||
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $reportPath
|
||||
}
|
||||
|
||||
if (-not $success) {
|
||||
throw "alertmanager live delivery drill failed: $failureReason"
|
||||
}
|
||||
64
scripts/ops/drill-alertmanager-render.ps1
Normal file
64
scripts/ops/drill-alertmanager-render.ps1
Normal file
@@ -0,0 +1,64 @@
|
||||
param(
|
||||
[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\alerting"
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$drillRoot = Join-Path $evidenceRoot $timestamp
|
||||
$renderedConfigPath = Join-Path $drillRoot 'alertmanager.rendered.yaml'
|
||||
$reportPath = Join-Path $drillRoot 'ALERTMANAGER_RENDER_DRILL.md'
|
||||
|
||||
New-Item -ItemType Directory -Force $evidenceRoot, $drillRoot | Out-Null
|
||||
|
||||
$env:ALERTMANAGER_DEFAULT_TO = 'ops-team@example.org'
|
||||
$env:ALERTMANAGER_CRITICAL_TO = 'critical-oncall@example.org'
|
||||
$env:ALERTMANAGER_WARNING_TO = 'warning-oncall@example.org'
|
||||
$env:ALERTMANAGER_FROM = 'alertmanager@example.org'
|
||||
$env:ALERTMANAGER_SMARTHOST = 'smtp.example.org:587'
|
||||
$env:ALERTMANAGER_AUTH_USERNAME = 'alertmanager@example.org'
|
||||
$env:ALERTMANAGER_AUTH_PASSWORD = 'synthetic-secret-for-render-drill'
|
||||
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot 'render-alertmanager-config.ps1') `
|
||||
-TemplatePath (Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml') `
|
||||
-OutputPath $renderedConfigPath | Out-Null
|
||||
} finally {
|
||||
Remove-Item Env:ALERTMANAGER_DEFAULT_TO -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALERTMANAGER_CRITICAL_TO -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALERTMANAGER_WARNING_TO -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALERTMANAGER_FROM -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALERTMANAGER_SMARTHOST -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALERTMANAGER_AUTH_USERNAME -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:ALERTMANAGER_AUTH_PASSWORD -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$renderedContent = Get-Content $renderedConfigPath -Raw -Encoding UTF8
|
||||
if ($renderedContent -match '\$\{[A-Z0-9_]+\}') {
|
||||
throw 'render drill failed: unresolved placeholders remained'
|
||||
}
|
||||
|
||||
$reportLines = @(
|
||||
'# Alertmanager Render Drill',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
"- Template file: $(Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml')",
|
||||
"- Rendered file: $renderedConfigPath",
|
||||
'- Synthetic secret values were injected through process environment variables for this drill only.',
|
||||
'- Result: template placeholders resolved successfully and the rendered config contains no unresolved `${ALERTMANAGER_*}` tokens.',
|
||||
'',
|
||||
'## Scope Note',
|
||||
'',
|
||||
'- This drill validates the config injection/rendering path only.',
|
||||
'- It does not prove real SMTP delivery, real contact routing, or production secret manager integration.',
|
||||
'',
|
||||
'## Evidence Files',
|
||||
'',
|
||||
"- $(Split-Path $renderedConfigPath -Leaf)",
|
||||
''
|
||||
)
|
||||
|
||||
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $reportPath
|
||||
249
scripts/ops/drill-config-isolation.ps1
Normal file
249
scripts/ops/drill-config-isolation.ps1
Normal file
@@ -0,0 +1,249 @@
|
||||
param(
|
||||
[string]$SourceDb = '',
|
||||
[int]$ConfigPort = 18085,
|
||||
[int]$EnvPort = 18086,
|
||||
[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\config-isolation"
|
||||
$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
|
||||
$isolatedDb = Join-Path $drillRoot 'user_management.isolated.db'
|
||||
$isolatedConfig = Join-Path $drillRoot 'config.isolated.yaml'
|
||||
$serverExe = Join-Path $drillRoot 'server-config-isolation.exe'
|
||||
$configOnlyStdOut = Join-Path $drillRoot 'config-only.stdout.log'
|
||||
$configOnlyStdErr = Join-Path $drillRoot 'config-only.stderr.log'
|
||||
$envOverrideStdOut = Join-Path $drillRoot 'env-override.stdout.log'
|
||||
$envOverrideStdErr = Join-Path $drillRoot 'env-override.stderr.log'
|
||||
$capabilitiesConfigOnlyPath = Join-Path $drillRoot 'capabilities.config-only.json'
|
||||
$capabilitiesEnvOverridePath = Join-Path $drillRoot 'capabilities.env-override.json'
|
||||
$reportPath = Join-Path $drillRoot 'CONFIG_ENV_ISOLATION_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-IsolatedConfig {
|
||||
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
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SourceDb)) {
|
||||
throw "source db not found: $SourceDb"
|
||||
}
|
||||
|
||||
Copy-Item $SourceDb $isolatedDb -Force
|
||||
Build-IsolatedConfig `
|
||||
-TemplatePath (Join-Path $projectRoot 'configs\config.yaml') `
|
||||
-OutputPath $isolatedConfig `
|
||||
-DbPath $isolatedDb `
|
||||
-Port $ConfigPort
|
||||
|
||||
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 config isolation server failed'
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$previousConfigPath = $env:UMS_CONFIG_PATH
|
||||
$previousServerPort = $env:UMS_SERVER_PORT
|
||||
$previousAllowedOrigins = $env:UMS_CORS_ALLOWED_ORIGINS
|
||||
|
||||
$configOnlyProcess = $null
|
||||
$envOverrideProcess = $null
|
||||
|
||||
try {
|
||||
$env:UMS_CONFIG_PATH = $isolatedConfig
|
||||
Remove-Item Env:UMS_SERVER_PORT -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:UMS_CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue
|
||||
|
||||
Remove-Item $configOnlyStdOut, $configOnlyStdErr -Force -ErrorAction SilentlyContinue
|
||||
$configOnlyProcess = Start-Process `
|
||||
-FilePath $serverExe `
|
||||
-WorkingDirectory $projectRoot `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $configOnlyStdOut `
|
||||
-RedirectStandardError $configOnlyStdErr
|
||||
|
||||
Wait-UrlReady -Url "http://127.0.0.1:$ConfigPort/health" -Label 'config-only health endpoint'
|
||||
Wait-UrlReady -Url "http://127.0.0.1:$ConfigPort/health/ready" -Label 'config-only readiness endpoint'
|
||||
$configOnlyCapabilities = Invoke-RestMethod "http://127.0.0.1:$ConfigPort/api/v1/auth/capabilities" -TimeoutSec 5
|
||||
Set-Content -Path $capabilitiesConfigOnlyPath -Value (($configOnlyCapabilities.data | ConvertTo-Json -Depth 6) + [Environment]::NewLine) -Encoding UTF8
|
||||
} finally {
|
||||
Stop-TreeProcess $configOnlyProcess
|
||||
}
|
||||
|
||||
try {
|
||||
$env:UMS_CONFIG_PATH = $isolatedConfig
|
||||
$env:UMS_SERVER_PORT = "$EnvPort"
|
||||
$env:UMS_CORS_ALLOWED_ORIGINS = 'https://admin.example.com'
|
||||
|
||||
Remove-Item $envOverrideStdOut, $envOverrideStdErr -Force -ErrorAction SilentlyContinue
|
||||
$envOverrideProcess = Start-Process `
|
||||
-FilePath $serverExe `
|
||||
-WorkingDirectory $projectRoot `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $envOverrideStdOut `
|
||||
-RedirectStandardError $envOverrideStdErr
|
||||
|
||||
Wait-UrlReady -Url "http://127.0.0.1:$EnvPort/health" -Label 'env-override health endpoint'
|
||||
Wait-UrlReady -Url "http://127.0.0.1:$EnvPort/health/ready" -Label 'env-override readiness endpoint'
|
||||
$envOverrideCapabilities = Invoke-RestMethod "http://127.0.0.1:$EnvPort/api/v1/auth/capabilities" -TimeoutSec 5
|
||||
Set-Content -Path $capabilitiesEnvOverridePath -Value (($envOverrideCapabilities.data | ConvertTo-Json -Depth 6) + [Environment]::NewLine) -Encoding UTF8
|
||||
|
||||
$corsAllowed = Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:$EnvPort/health" `
|
||||
-Headers @{ Origin = 'https://admin.example.com' } `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 5
|
||||
$corsRejected = Invoke-WebRequest `
|
||||
-Uri "http://127.0.0.1:$EnvPort/health" `
|
||||
-Headers @{ Origin = 'http://localhost:3000' } `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 5
|
||||
} finally {
|
||||
Stop-TreeProcess $envOverrideProcess
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($previousConfigPath)) {
|
||||
Remove-Item Env:UMS_CONFIG_PATH -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UMS_CONFIG_PATH = $previousConfigPath
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($previousServerPort)) {
|
||||
Remove-Item Env:UMS_SERVER_PORT -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UMS_SERVER_PORT = $previousServerPort
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($previousAllowedOrigins)) {
|
||||
Remove-Item Env:UMS_CORS_ALLOWED_ORIGINS -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UMS_CORS_ALLOWED_ORIGINS = $previousAllowedOrigins
|
||||
}
|
||||
}
|
||||
|
||||
$corsAllowedOrigin = $corsAllowed.Headers['Access-Control-Allow-Origin']
|
||||
$corsRejectedOrigin = $corsRejected.Headers['Access-Control-Allow-Origin']
|
||||
|
||||
if ($corsAllowedOrigin -ne 'https://admin.example.com') {
|
||||
throw "expected env override CORS allow origin to be https://admin.example.com, got: $corsAllowedOrigin"
|
||||
}
|
||||
if (-not [string]::IsNullOrWhiteSpace($corsRejectedOrigin)) {
|
||||
throw "expected localhost origin to be excluded by env override, got: $corsRejectedOrigin"
|
||||
}
|
||||
|
||||
$reportLines = @(
|
||||
'# Config And Env Isolation Drill',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
"- Source DB: $SourceDb",
|
||||
"- Isolated DB: $isolatedDb",
|
||||
"- Isolated config: $isolatedConfig",
|
||||
'',
|
||||
'## Verification Results',
|
||||
'',
|
||||
"- Base config default port: 8080",
|
||||
"- UMS_CONFIG_PATH isolated port: $ConfigPort",
|
||||
"- UMS_SERVER_PORT override port: $EnvPort",
|
||||
"- UMS_CORS_ALLOWED_ORIGINS override accepted origin: $corsAllowedOrigin",
|
||||
"- UMS_CORS_ALLOWED_ORIGINS override excluded origin: $(if ([string]::IsNullOrWhiteSpace($corsRejectedOrigin)) { 'none' } else { $corsRejectedOrigin })",
|
||||
"- auth capabilities with config-only override: $(($configOnlyCapabilities.data | ConvertTo-Json -Compress))",
|
||||
"- auth capabilities with env override: $(($envOverrideCapabilities.data | ConvertTo-Json -Compress))",
|
||||
'',
|
||||
'## Evidence Files',
|
||||
'',
|
||||
"- $(Split-Path $configOnlyStdOut -Leaf)",
|
||||
"- $(Split-Path $configOnlyStdErr -Leaf)",
|
||||
"- $(Split-Path $envOverrideStdOut -Leaf)",
|
||||
"- $(Split-Path $envOverrideStdErr -Leaf)",
|
||||
"- $(Split-Path $capabilitiesConfigOnlyPath -Leaf)",
|
||||
"- $(Split-Path $capabilitiesEnvOverridePath -Leaf)",
|
||||
"- $(Split-Path $isolatedConfig -Leaf)",
|
||||
''
|
||||
)
|
||||
|
||||
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $reportPath
|
||||
259
scripts/ops/drill-local-rollback.ps1
Normal file
259
scripts/ops/drill-local-rollback.ps1
Normal file
@@ -0,0 +1,259 @@
|
||||
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
|
||||
240
scripts/ops/drill-sqlite-backup-restore.ps1
Normal file
240
scripts/ops/drill-sqlite-backup-restore.ps1
Normal file
@@ -0,0 +1,240 @@
|
||||
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
|
||||
65
scripts/ops/render-alertmanager-config.ps1
Normal file
65
scripts/ops/render-alertmanager-config.ps1
Normal file
@@ -0,0 +1,65 @@
|
||||
param(
|
||||
[string]$TemplatePath = 'D:\project\deployment\alertmanager\alertmanager.yml',
|
||||
[string]$OutputPath,
|
||||
[string]$EnvFilePath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
|
||||
throw 'OutputPath is required'
|
||||
}
|
||||
|
||||
if (-not (Test-Path $TemplatePath)) {
|
||||
throw "template not found: $TemplatePath"
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($EnvFilePath)) {
|
||||
if (-not (Test-Path $EnvFilePath)) {
|
||||
throw "env file not found: $EnvFilePath"
|
||||
}
|
||||
|
||||
Get-Content $EnvFilePath -Encoding UTF8 | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if ($line -eq '' -or $line.StartsWith('#')) {
|
||||
return
|
||||
}
|
||||
$parts = $line -split '=', 2
|
||||
if ($parts.Count -ne 2) {
|
||||
throw "invalid env line: $line"
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($parts[0].Trim(), $parts[1].Trim(), 'Process')
|
||||
}
|
||||
}
|
||||
|
||||
$content = Get-Content $TemplatePath -Raw -Encoding UTF8
|
||||
$matches = [regex]::Matches($content, '\$\{(?<name>[A-Z0-9_]+)\}')
|
||||
$variables = @($matches | ForEach-Object { $_.Groups['name'].Value } | Sort-Object -Unique)
|
||||
$missing = @()
|
||||
|
||||
foreach ($name in $variables) {
|
||||
$value = [Environment]::GetEnvironmentVariable($name, 'Process')
|
||||
if ([string]::IsNullOrWhiteSpace($value)) {
|
||||
$missing += $name
|
||||
continue
|
||||
}
|
||||
$escapedToken = [regex]::Escape('${' + $name + '}')
|
||||
$escapedValue = $value -replace '\\', '\\'
|
||||
$content = [regex]::Replace($content, $escapedToken, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) $value })
|
||||
}
|
||||
|
||||
if ($missing.Count -gt 0) {
|
||||
throw "missing required alertmanager environment variables: $($missing -join ', ')"
|
||||
}
|
||||
|
||||
if ($content -match '\$\{[A-Z0-9_]+\}') {
|
||||
throw 'rendered alertmanager config still contains unresolved placeholders'
|
||||
}
|
||||
|
||||
$outputDir = Split-Path $OutputPath -Parent
|
||||
if (-not [string]::IsNullOrWhiteSpace($outputDir)) {
|
||||
New-Item -ItemType Directory -Force $outputDir | Out-Null
|
||||
}
|
||||
|
||||
Set-Content -Path $OutputPath -Value $content -Encoding UTF8
|
||||
Get-Content $OutputPath
|
||||
196
scripts/ops/run-sca-evidence.ps1
Normal file
196
scripts/ops/run-sca-evidence.ps1
Normal file
@@ -0,0 +1,196 @@
|
||||
param(
|
||||
[string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$frontendRoot = Join-Path $projectRoot 'frontend\admin'
|
||||
$evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\sca"
|
||||
$goCacheRoot = Join-Path $projectRoot '.cache'
|
||||
$goBuildCache = Join-Path $goCacheRoot 'go-build'
|
||||
$goModCache = Join-Path $goCacheRoot 'gomod'
|
||||
$goPath = Join-Path $goCacheRoot 'gopath'
|
||||
|
||||
New-Item -ItemType Directory -Force $evidenceRoot, $goBuildCache, $goModCache, $goPath | Out-Null
|
||||
|
||||
function Invoke-CapturedCommand {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[string[]]$ArgumentList = @(),
|
||||
[Parameter(Mandatory = $true)][string]$WorkingDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$StdOutPath,
|
||||
[Parameter(Mandatory = $true)][string]$StdErrPath
|
||||
)
|
||||
|
||||
Remove-Item $StdOutPath, $StdErrPath -Force -ErrorAction SilentlyContinue
|
||||
$process = Start-Process `
|
||||
-FilePath $FilePath `
|
||||
-ArgumentList $ArgumentList `
|
||||
-WorkingDirectory $WorkingDirectory `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $StdOutPath `
|
||||
-RedirectStandardError $StdErrPath `
|
||||
-Wait
|
||||
|
||||
return $process.ExitCode
|
||||
}
|
||||
|
||||
function Get-NpmAuditCounts {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$JsonPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $JsonPath)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$raw = Get-Content $JsonPath -Raw
|
||||
if ([string]::IsNullOrWhiteSpace($raw)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$payload = $raw | ConvertFrom-Json
|
||||
if (-not $payload.metadata -or -not $payload.metadata.vulnerabilities) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $payload.metadata.vulnerabilities
|
||||
}
|
||||
|
||||
function Get-GovulnFindingCount {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$JsonPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $JsonPath)) {
|
||||
return [pscustomobject]@{
|
||||
Count = 0
|
||||
IDs = @()
|
||||
}
|
||||
}
|
||||
|
||||
$count = 0
|
||||
$ids = New-Object System.Collections.Generic.HashSet[string]
|
||||
$insideFinding = $false
|
||||
foreach ($line in Get-Content $JsonPath) {
|
||||
if ($line -match '"finding"') {
|
||||
$insideFinding = $true
|
||||
$count++
|
||||
continue
|
||||
}
|
||||
if ($insideFinding -and $line -match '"osv":\s*"([^"]+)"') {
|
||||
[void]$ids.Add($Matches[1])
|
||||
$insideFinding = $false
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Count = $count
|
||||
IDs = @($ids)
|
||||
}
|
||||
}
|
||||
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$prodAuditJson = Join-Path $evidenceRoot "npm-audit-prod-$timestamp.json"
|
||||
$prodAuditErr = Join-Path $evidenceRoot "npm-audit-prod-$timestamp.stderr.txt"
|
||||
$fullAuditJson = Join-Path $evidenceRoot "npm-audit-full-$timestamp.json"
|
||||
$fullAuditErr = Join-Path $evidenceRoot "npm-audit-full-$timestamp.stderr.txt"
|
||||
$govulnJson = Join-Path $evidenceRoot "govulncheck-$timestamp.jsonl"
|
||||
$govulnErr = Join-Path $evidenceRoot "govulncheck-$timestamp.stderr.txt"
|
||||
$summaryPath = Join-Path $evidenceRoot "SCA_SUMMARY_$timestamp.md"
|
||||
|
||||
$prodAuditExit = Invoke-CapturedCommand `
|
||||
-FilePath 'npm.cmd' `
|
||||
-ArgumentList @('audit', '--omit=dev', '--json', '--registry=https://registry.npmjs.org/') `
|
||||
-WorkingDirectory $frontendRoot `
|
||||
-StdOutPath $prodAuditJson `
|
||||
-StdErrPath $prodAuditErr
|
||||
|
||||
$fullAuditExit = Invoke-CapturedCommand `
|
||||
-FilePath 'npm.cmd' `
|
||||
-ArgumentList @('audit', '--json', '--registry=https://registry.npmjs.org/') `
|
||||
-WorkingDirectory $frontendRoot `
|
||||
-StdOutPath $fullAuditJson `
|
||||
-StdErrPath $fullAuditErr
|
||||
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
$env:GOCACHE = $goBuildCache
|
||||
$env:GOMODCACHE = $goModCache
|
||||
$env:GOPATH = $goPath
|
||||
$govulnExit = Invoke-CapturedCommand `
|
||||
-FilePath 'go' `
|
||||
-ArgumentList @('run', 'golang.org/x/vuln/cmd/govulncheck@latest', '-json', './...') `
|
||||
-WorkingDirectory $projectRoot `
|
||||
-StdOutPath $govulnJson `
|
||||
-StdErrPath $govulnErr
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$prodCounts = Get-NpmAuditCounts -JsonPath $prodAuditJson
|
||||
$fullCounts = Get-NpmAuditCounts -JsonPath $fullAuditJson
|
||||
$govulnFindings = Get-GovulnFindingCount -JsonPath $govulnJson
|
||||
$prodFindingSummary = if ($prodCounts) {
|
||||
"info=$($prodCounts.info) low=$($prodCounts.low) moderate=$($prodCounts.moderate) high=$($prodCounts.high) critical=$($prodCounts.critical) total=$($prodCounts.total)"
|
||||
} else {
|
||||
'unavailable'
|
||||
}
|
||||
$fullFindingSummary = if ($fullCounts) {
|
||||
"info=$($fullCounts.info) low=$($fullCounts.low) moderate=$($fullCounts.moderate) high=$($fullCounts.high) critical=$($fullCounts.critical) total=$($fullCounts.total)"
|
||||
} else {
|
||||
'unavailable'
|
||||
}
|
||||
$govulnIDsSummary = if ($govulnFindings.IDs.Count -gt 0) {
|
||||
($govulnFindings.IDs | Sort-Object) -join ', '
|
||||
} else {
|
||||
'none'
|
||||
}
|
||||
$prodAuditJsonName = Split-Path $prodAuditJson -Leaf
|
||||
$prodAuditErrName = Split-Path $prodAuditErr -Leaf
|
||||
$fullAuditJsonName = Split-Path $fullAuditJson -Leaf
|
||||
$fullAuditErrName = Split-Path $fullAuditErr -Leaf
|
||||
$govulnJsonName = Split-Path $govulnJson -Leaf
|
||||
$govulnErrName = Split-Path $govulnErr -Leaf
|
||||
|
||||
$summaryLines = @(
|
||||
'# SCA Summary',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
"- Project root: $projectRoot",
|
||||
'',
|
||||
'## Commands',
|
||||
'',
|
||||
'- `cd frontend/admin && npm.cmd audit --omit=dev --json --registry=https://registry.npmjs.org/`',
|
||||
'- `cd frontend/admin && npm.cmd audit --json --registry=https://registry.npmjs.org/`',
|
||||
'- `go run golang.org/x/vuln/cmd/govulncheck@latest -json ./...`',
|
||||
'',
|
||||
'## Exit Codes',
|
||||
'',
|
||||
"- npm audit production: $prodAuditExit",
|
||||
"- npm audit full: $fullAuditExit",
|
||||
"- govulncheck: $govulnExit",
|
||||
'',
|
||||
'## Findings',
|
||||
'',
|
||||
"- npm audit production: $prodFindingSummary",
|
||||
"- npm audit full: $fullFindingSummary",
|
||||
"- govulncheck reachable findings: $($govulnFindings.Count)",
|
||||
"- govulncheck reachable IDs: $govulnIDsSummary",
|
||||
'',
|
||||
'## Evidence Files',
|
||||
'',
|
||||
"- $prodAuditJsonName",
|
||||
"- $prodAuditErrName",
|
||||
"- $fullAuditJsonName",
|
||||
"- $fullAuditErrName",
|
||||
"- $govulnJsonName",
|
||||
"- $govulnErrName",
|
||||
''
|
||||
)
|
||||
|
||||
Set-Content -Path $summaryPath -Value ($summaryLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $summaryPath
|
||||
222
scripts/ops/validate-alerting-package.ps1
Normal file
222
scripts/ops/validate-alerting-package.ps1
Normal file
@@ -0,0 +1,222 @@
|
||||
param(
|
||||
[string]$EvidenceDate = (Get-Date -Format 'yyyy-MM-dd'),
|
||||
[string]$BaselineReportPath = '',
|
||||
[string]$AlertmanagerPath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$alertsPath = Join-Path $projectRoot 'deployment\alertmanager\alerts.yml'
|
||||
$alertmanagerPath = if ([string]::IsNullOrWhiteSpace($AlertmanagerPath)) {
|
||||
Join-Path $projectRoot 'deployment\alertmanager\alertmanager.yml'
|
||||
} else {
|
||||
$AlertmanagerPath
|
||||
}
|
||||
$evidenceRoot = Join-Path $projectRoot "docs\evidence\ops\$EvidenceDate\alerting"
|
||||
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$reportPath = Join-Path $evidenceRoot "ALERTING_PACKAGE_$timestamp.md"
|
||||
|
||||
New-Item -ItemType Directory -Force $evidenceRoot | Out-Null
|
||||
|
||||
function Get-LatestBaselineReportPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ProjectRoot,
|
||||
[Parameter(Mandatory = $true)][string]$EvidenceDate
|
||||
)
|
||||
|
||||
$observabilityRoot = Join-Path $ProjectRoot "docs\evidence\ops\$EvidenceDate\observability"
|
||||
$latest = Get-ChildItem $observabilityRoot -Filter 'LOCAL_BASELINE_*.md' -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($latest) {
|
||||
return $latest.FullName
|
||||
}
|
||||
|
||||
$fallbackRoot = Join-Path $ProjectRoot 'docs\evidence\ops'
|
||||
$fallback = Get-ChildItem $fallbackRoot -Recurse -Filter 'LOCAL_BASELINE_*.md' -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $fallback) {
|
||||
throw "baseline report not found under $observabilityRoot or $fallbackRoot"
|
||||
}
|
||||
|
||||
return $fallback.FullName
|
||||
}
|
||||
|
||||
function Parse-AlertRules {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
|
||||
$matches = [regex]::Matches($Content, '(?ms)^\s*-\s*alert:\s*(?<name>[^\r\n]+)(?<body>.*?)(?=^\s*-\s*alert:|\z)')
|
||||
$rules = @()
|
||||
|
||||
foreach ($match in $matches) {
|
||||
$body = $match.Groups['body'].Value
|
||||
$severityMatch = [regex]::Match($body, '(?m)^\s*severity:\s*(?<severity>[^\r\n]+)')
|
||||
$forMatch = [regex]::Match($body, '(?m)^\s*for:\s*(?<duration>[^\r\n]+)')
|
||||
$exprMatch = [regex]::Match($body, '(?ms)^\s*expr:\s*\|?\s*(?<expr>.*?)(?=^\s*for:|^\s*labels:|\z)')
|
||||
|
||||
$rules += [pscustomobject]@{
|
||||
Name = $match.Groups['name'].Value.Trim()
|
||||
Severity = $severityMatch.Groups['severity'].Value.Trim()
|
||||
For = $forMatch.Groups['duration'].Value.Trim()
|
||||
Expr = $exprMatch.Groups['expr'].Value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
return $rules
|
||||
}
|
||||
|
||||
function Parse-AlertmanagerRoutes {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
|
||||
$rootReceiverMatch = [regex]::Match($Content, '(?m)^\s*receiver:\s*''(?<receiver>[^'']+)''')
|
||||
$routeMatches = [regex]::Matches($Content, '(?ms)^\s*-\s*match:\s*(?<body>.*?)(?=^\s*-\s*match:|^\s*receivers:|\z)')
|
||||
$routes = @()
|
||||
|
||||
foreach ($match in $routeMatches) {
|
||||
$body = $match.Groups['body'].Value
|
||||
$severityMatch = [regex]::Match($body, '(?m)^\s*severity:\s*(?<severity>[^\r\n]+)')
|
||||
$receiverMatch = [regex]::Match($body, '(?m)^\s*receiver:\s*''(?<receiver>[^'']+)''')
|
||||
$routes += [pscustomobject]@{
|
||||
Severity = $severityMatch.Groups['severity'].Value.Trim()
|
||||
Receiver = $receiverMatch.Groups['receiver'].Value.Trim()
|
||||
}
|
||||
}
|
||||
|
||||
$receiverMatches = [regex]::Matches($Content, '(?m)^\s*-\s*name:\s*''(?<name>[^'']+)''')
|
||||
$receivers = @($receiverMatches | ForEach-Object { $_.Groups['name'].Value.Trim() })
|
||||
|
||||
return [pscustomobject]@{
|
||||
RootReceiver = $rootReceiverMatch.Groups['receiver'].Value.Trim()
|
||||
Routes = $routes
|
||||
Receivers = $receivers
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PlaceholderFindings {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
|
||||
$findings = @()
|
||||
foreach ($pattern in @(
|
||||
'\$\{ALERTMANAGER_[A-Z0-9_]+\}',
|
||||
'admin@example\.com',
|
||||
'ops-team@example\.com',
|
||||
'dev-team@example\.com',
|
||||
'alertmanager@example\.com',
|
||||
'smtp\.example\.com',
|
||||
'auth_password:\s*''password'''
|
||||
)) {
|
||||
if ($Content -match $pattern) {
|
||||
$findings += $pattern
|
||||
}
|
||||
}
|
||||
return $findings
|
||||
}
|
||||
|
||||
function Get-BaselineTimings {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Content
|
||||
)
|
||||
|
||||
$timings = @{}
|
||||
foreach ($name in @('login-initial', 'login-desktop', 'login-tablet', 'login-mobile')) {
|
||||
$match = [regex]::Match($Content, [regex]::Escape($name) + ':\s*([0-9]+)ms')
|
||||
if ($match.Success) {
|
||||
$timings[$name] = [int]$match.Groups[1].Value
|
||||
}
|
||||
}
|
||||
return $timings
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($BaselineReportPath)) {
|
||||
$BaselineReportPath = Get-LatestBaselineReportPath -ProjectRoot $projectRoot -EvidenceDate $EvidenceDate
|
||||
}
|
||||
|
||||
$alertsContent = Get-Content $alertsPath -Raw -Encoding UTF8
|
||||
$alertmanagerContent = Get-Content $alertmanagerPath -Raw -Encoding UTF8
|
||||
$baselineContent = Get-Content $BaselineReportPath -Raw -Encoding UTF8
|
||||
|
||||
$rules = Parse-AlertRules -Content $alertsContent
|
||||
$routeConfig = Parse-AlertmanagerRoutes -Content $alertmanagerContent
|
||||
$placeholderFindings = Get-PlaceholderFindings -Content $alertmanagerContent
|
||||
$baselineTimings = Get-BaselineTimings -Content $baselineContent
|
||||
$requiredRules = @(
|
||||
'HighErrorRate',
|
||||
'HighResponseTime',
|
||||
'DatabaseConnectionPoolExhausted',
|
||||
'HighLoginFailureRate'
|
||||
)
|
||||
$missingRules = @($requiredRules | Where-Object { $rules.Name -notcontains $_ })
|
||||
$criticalRoute = $routeConfig.Routes | Where-Object { $_.Severity -eq 'critical' } | Select-Object -First 1
|
||||
$warningRoute = $routeConfig.Routes | Where-Object { $_.Severity -eq 'warning' } | Select-Object -First 1
|
||||
$requiredReceivers = @('default', 'critical-alerts', 'warning-alerts')
|
||||
$missingReceivers = @($requiredReceivers | Where-Object { $routeConfig.Receivers -notcontains $_ })
|
||||
$highResponseRule = $rules | Where-Object { $_.Name -eq 'HighResponseTime' } | Select-Object -First 1
|
||||
$highResponseThresholdSeconds = $null
|
||||
|
||||
if ($highResponseRule -and $highResponseRule.Expr -match '>\s*(?<threshold>[0-9.]+)') {
|
||||
$highResponseThresholdSeconds = [double]$Matches['threshold']
|
||||
}
|
||||
|
||||
$maxBaselineMs = 0
|
||||
if ($baselineTimings.Count -gt 0) {
|
||||
$maxBaselineMs = ($baselineTimings.Values | Measure-Object -Maximum).Maximum
|
||||
}
|
||||
|
||||
$ruleInventory = @(
|
||||
"critical=$((@($rules | Where-Object { $_.Severity -eq 'critical' })).Count)",
|
||||
"warning=$((@($rules | Where-Object { $_.Severity -eq 'warning' })).Count)",
|
||||
"info=$((@($rules | Where-Object { $_.Severity -eq 'info' })).Count)"
|
||||
) -join ', '
|
||||
|
||||
$structuralReady = ($missingRules.Count -eq 0) -and ($missingReceivers.Count -eq 0) -and -not [string]::IsNullOrWhiteSpace($routeConfig.RootReceiver) -and $criticalRoute -and $warningRoute
|
||||
$externalDeliveryClosed = $placeholderFindings.Count -eq 0
|
||||
|
||||
$reportLines = @(
|
||||
'# Alerting Package Validation',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
"- Alerts file: $alertsPath",
|
||||
"- Alertmanager file: $alertmanagerPath",
|
||||
"- Baseline report: $BaselineReportPath",
|
||||
'',
|
||||
'## Structural Validation',
|
||||
'',
|
||||
"- Rule inventory: $ruleInventory",
|
||||
"- Missing required rules: $(if ($missingRules.Count -gt 0) { $missingRules -join ', ' } else { 'none' })",
|
||||
"- Root receiver: $($routeConfig.RootReceiver)",
|
||||
"- Critical route receiver: $(if ($criticalRoute) { $criticalRoute.Receiver } else { 'missing' })",
|
||||
"- Warning route receiver: $(if ($warningRoute) { $warningRoute.Receiver } else { 'missing' })",
|
||||
"- Missing required receivers: $(if ($missingReceivers.Count -gt 0) { $missingReceivers -join ', ' } else { 'none' })",
|
||||
"- Structural ready: $structuralReady",
|
||||
'',
|
||||
'## Threshold Alignment',
|
||||
'',
|
||||
"- HighResponseTime threshold: $(if ($null -ne $highResponseThresholdSeconds) { $highResponseThresholdSeconds.ToString() + 's' } else { 'unparsed' })",
|
||||
"- Latest browser max baseline: ${maxBaselineMs}ms",
|
||||
"- Latest browser timings: $(if ($baselineTimings.Count -gt 0) { ($baselineTimings.GetEnumerator() | Sort-Object Name | ForEach-Object { '{0}={1}ms' -f $_.Name, $_.Value }) -join ', ' } else { 'unavailable' })",
|
||||
'',
|
||||
'## External Delivery Readiness',
|
||||
'',
|
||||
"- Placeholder findings: $(if ($placeholderFindings.Count -gt 0) { $placeholderFindings -join ', ' } else { 'none' })",
|
||||
"- External delivery closed: $externalDeliveryClosed",
|
||||
'- Interpretation: rules and route topology can be reviewed locally, but unresolved template variables or example SMTP/accounts mean real notification delivery evidence is still open until environment-specific contacts and secrets are injected.',
|
||||
'',
|
||||
'## Conclusion',
|
||||
'',
|
||||
"- Repo-level alerting package structurally ready: $structuralReady",
|
||||
"- Repo-level oncall/delivery package fully closed: $externalDeliveryClosed",
|
||||
''
|
||||
)
|
||||
|
||||
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $reportPath
|
||||
286
scripts/ops/validate-secret-boundary.ps1
Normal file
286
scripts/ops/validate-secret-boundary.ps1
Normal file
@@ -0,0 +1,286 @@
|
||||
param(
|
||||
[string]$SourceDb = '',
|
||||
[int]$ProbePort = 18088,
|
||||
[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\secret-boundary"
|
||||
$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
|
||||
$isolatedDb = Join-Path $drillRoot 'user_management.secret-boundary.db'
|
||||
$isolatedConfig = Join-Path $drillRoot 'config.secret-boundary.yaml'
|
||||
$serverExe = Join-Path $drillRoot 'server-secret-boundary.exe'
|
||||
$serverStdOut = Join-Path $drillRoot 'server.stdout.log'
|
||||
$serverStdErr = Join-Path $drillRoot 'server.stderr.log'
|
||||
$capabilitiesPath = Join-Path $drillRoot 'capabilities.json'
|
||||
$reportPath = Join-Path $drillRoot 'SECRET_BOUNDARY_DRILL.md'
|
||||
$syntheticJWTSecret = 'secret-boundary-drill-0123456789abcdef-UVWXYZ'
|
||||
|
||||
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-IsolatedConfig {
|
||||
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 -Encoding UTF8
|
||||
$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 Get-ConfigBlock {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[int]$Indent = 0
|
||||
)
|
||||
|
||||
$currentIndent = ' ' * $Indent
|
||||
$childIndent = ' ' * ($Indent + 2)
|
||||
$pattern = "(?ms)^$([regex]::Escape($currentIndent))$([regex]::Escape($Name)):\s*\r?\n(?<body>(?:^$([regex]::Escape($childIndent)).*\r?\n)*)"
|
||||
$match = [regex]::Match($Content, $pattern)
|
||||
if (-not $match.Success) {
|
||||
throw "config block not found: $Name"
|
||||
}
|
||||
|
||||
return $match.Groups['body'].Value
|
||||
}
|
||||
|
||||
function Get-QuotedFieldValue {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$Field
|
||||
)
|
||||
|
||||
$match = [regex]::Match($Content, "(?m)^\s*$([regex]::Escape($Field)):\s*`"(?<value>.*)`"\s*$")
|
||||
if (-not $match.Success) {
|
||||
throw "quoted field not found: $Field"
|
||||
}
|
||||
|
||||
return $match.Groups['value'].Value
|
||||
}
|
||||
|
||||
if (-not (Test-Path $SourceDb)) {
|
||||
throw "source db not found: $SourceDb"
|
||||
}
|
||||
|
||||
$configPath = Join-Path $projectRoot 'configs\config.yaml'
|
||||
$configContent = Get-Content $configPath -Raw -Encoding UTF8
|
||||
$gitignorePath = Join-Path $projectRoot '.gitignore'
|
||||
$gitignoreContent = Get-Content $gitignorePath -Raw -Encoding UTF8
|
||||
|
||||
$jwtBlock = Get-ConfigBlock -Content $configContent -Name 'jwt'
|
||||
$databaseBlock = Get-ConfigBlock -Content $configContent -Name 'database'
|
||||
$postgresBlock = Get-ConfigBlock -Content $databaseBlock -Name 'postgresql' -Indent 2
|
||||
$mysqlBlock = Get-ConfigBlock -Content $databaseBlock -Name 'mysql' -Indent 2
|
||||
|
||||
$jwtSecretTemplateValue = Get-QuotedFieldValue -Content $jwtBlock -Field 'secret'
|
||||
$postgresPasswordValue = Get-QuotedFieldValue -Content $postgresBlock -Field 'password'
|
||||
$mysqlPasswordValue = Get-QuotedFieldValue -Content $mysqlBlock -Field 'password'
|
||||
|
||||
if ($jwtSecretTemplateValue -ne '') {
|
||||
throw "expected jwt.secret in config template to be blank, got: $jwtSecretTemplateValue"
|
||||
}
|
||||
if ($postgresPasswordValue -ne '') {
|
||||
throw 'expected postgresql.password in config template to be blank'
|
||||
}
|
||||
if ($mysqlPasswordValue -ne '') {
|
||||
throw 'expected mysql.password in config template to be blank'
|
||||
}
|
||||
|
||||
foreach ($forbiddenToken in @(
|
||||
'your-secret-key-change-in-production',
|
||||
'replace-with-secret'
|
||||
)) {
|
||||
if ($configContent -match [regex]::Escape($forbiddenToken)) {
|
||||
throw "forbidden placeholder still present in config template: $forbiddenToken"
|
||||
}
|
||||
}
|
||||
|
||||
if ($gitignoreContent -notmatch '(?m)^data/jwt/\*\.pem\r?$') {
|
||||
throw '.gitignore is missing data/jwt/*.pem'
|
||||
}
|
||||
if ($gitignoreContent -notmatch '(?m)^\.env\r?$') {
|
||||
throw '.gitignore is missing .env'
|
||||
}
|
||||
if ($gitignoreContent -notmatch '(?m)^\.env\.local\r?$') {
|
||||
throw '.gitignore is missing .env.local'
|
||||
}
|
||||
|
||||
Copy-Item $SourceDb $isolatedDb -Force
|
||||
Build-IsolatedConfig `
|
||||
-TemplatePath $configPath `
|
||||
-OutputPath $isolatedConfig `
|
||||
-DbPath $isolatedDb `
|
||||
-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 secret boundary server failed'
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
Remove-Item Env:GOCACHE, Env:GOMODCACHE, Env:GOPATH -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$previousConfigPath = $env:UMS_CONFIG_PATH
|
||||
$previousJWTAlgorithm = $env:UMS_JWT_ALGORITHM
|
||||
$previousJWTSecret = $env:UMS_JWT_SECRET
|
||||
$serverProcess = $null
|
||||
|
||||
try {
|
||||
$env:UMS_CONFIG_PATH = $isolatedConfig
|
||||
$env:UMS_JWT_ALGORITHM = 'HS256'
|
||||
$env:UMS_JWT_SECRET = $syntheticJWTSecret
|
||||
|
||||
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 'secret boundary health endpoint'
|
||||
Wait-UrlReady -Url "http://127.0.0.1:$ProbePort/health/ready" -Label 'secret boundary readiness endpoint'
|
||||
$capabilities = Invoke-RestMethod "http://127.0.0.1:$ProbePort/api/v1/auth/capabilities" -TimeoutSec 5
|
||||
Set-Content -Path $capabilitiesPath -Value (($capabilities.data | ConvertTo-Json -Depth 6) + [Environment]::NewLine) -Encoding UTF8
|
||||
} finally {
|
||||
Stop-TreeProcess $serverProcess
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($previousConfigPath)) {
|
||||
Remove-Item Env:UMS_CONFIG_PATH -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UMS_CONFIG_PATH = $previousConfigPath
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($previousJWTAlgorithm)) {
|
||||
Remove-Item Env:UMS_JWT_ALGORITHM -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UMS_JWT_ALGORITHM = $previousJWTAlgorithm
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($previousJWTSecret)) {
|
||||
Remove-Item Env:UMS_JWT_SECRET -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:UMS_JWT_SECRET = $previousJWTSecret
|
||||
}
|
||||
}
|
||||
|
||||
$reportLines = @(
|
||||
'# Secret Boundary Drill',
|
||||
'',
|
||||
"- Generated at: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')",
|
||||
"- Source DB: $SourceDb",
|
||||
"- Isolated DB: $isolatedDb",
|
||||
"- Isolated config: $isolatedConfig",
|
||||
'',
|
||||
'## Template Validation',
|
||||
'',
|
||||
"- config template jwt.secret blank: $($jwtSecretTemplateValue -eq '')",
|
||||
"- config template postgresql.password blank: $($postgresPasswordValue -eq '')",
|
||||
"- config template mysql.password blank: $($mysqlPasswordValue -eq '')",
|
||||
'- forbidden placeholders removed from configs/config.yaml: True',
|
||||
"- .gitignore protects local JWT key files: $($gitignoreContent -match '(?m)^data/jwt/\*\.pem\r?$')",
|
||||
"- .gitignore protects .env files: $($gitignoreContent -match '(?m)^\.env\r?$' -and $gitignoreContent -match '(?m)^\.env\.local\r?$')",
|
||||
'',
|
||||
'## Runtime Injection Validation',
|
||||
'',
|
||||
'- Startup path: UMS_CONFIG_PATH + UMS_JWT_ALGORITHM + UMS_JWT_SECRET',
|
||||
"- Synthetic JWT algorithm injected: HS256",
|
||||
"- Synthetic JWT secret length: $($syntheticJWTSecret.Length)",
|
||||
'- GET /health: pass',
|
||||
'- GET /health/ready: pass',
|
||||
"- GET /api/v1/auth/capabilities: $(($capabilities.data | ConvertTo-Json -Compress))",
|
||||
'',
|
||||
'## Scope Note',
|
||||
'',
|
||||
'- This drill proves the repo-level secret boundary and environment injection path are executable locally.',
|
||||
'- It does not prove external secrets manager, KMS rotation, or CI/CD environment delivery evidence.',
|
||||
'',
|
||||
'## Evidence Files',
|
||||
'',
|
||||
"- $(Split-Path $serverStdOut -Leaf)",
|
||||
"- $(Split-Path $serverStdErr -Leaf)",
|
||||
"- $(Split-Path $capabilitiesPath -Leaf)",
|
||||
"- $(Split-Path $isolatedConfig -Leaf)",
|
||||
''
|
||||
)
|
||||
|
||||
Set-Content -Path $reportPath -Value ($reportLines -join [Environment]::NewLine) -Encoding UTF8
|
||||
Get-Content $reportPath
|
||||
64
scripts/validate.ps1
Normal file
64
scripts/validate.ps1
Normal file
@@ -0,0 +1,64 @@
|
||||
# Project Robustness Validation Script
|
||||
$ErrorActionPreference = "Continue"
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
Set-Location $ProjectRoot
|
||||
|
||||
Write-Host "======================================" -ForegroundColor Cyan
|
||||
Write-Host " Project Robustness Validation" -ForegroundColor Cyan
|
||||
Write-Host "======================================" -ForegroundColor Cyan
|
||||
|
||||
# 1. Check Go
|
||||
Write-Host "`n[1] Checking Go..." -ForegroundColor Yellow
|
||||
$goVersion = go version 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host "OK: $goVersion" -ForegroundColor Green }
|
||||
else { Write-Host "FAIL: Go not installed" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 2. Check dependencies
|
||||
Write-Host "`n[2] Checking dependencies..." -ForegroundColor Yellow
|
||||
go mod tidy 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host "OK: Dependencies OK" -ForegroundColor Green }
|
||||
else { Write-Host "WARN: Dependencies issue" -ForegroundColor Yellow }
|
||||
|
||||
# 3. Static analysis
|
||||
Write-Host "`n[3] Running vet..." -ForegroundColor Yellow
|
||||
go vet ./... 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host "OK: No static errors" -ForegroundColor Green }
|
||||
else { Write-Host "WARN: Static errors found" -ForegroundColor Yellow }
|
||||
|
||||
# 4. Build
|
||||
Write-Host "`n[4] Building..." -ForegroundColor Yellow
|
||||
if (-not (Test-Path "data")) { New-Item -ItemType Directory -Path "data" -Force | Out-Null }
|
||||
# Only build main packages, skip docs and e2e (they have special requirements)
|
||||
go build -o /dev/null ./cmd/server 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host "OK: Build success" -ForegroundColor Green }
|
||||
else { Write-Host "FAIL: Build failed" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 5. Test
|
||||
Write-Host "`n[5] Running tests..." -ForegroundColor Yellow
|
||||
# Skip docs (has swagger generation issues) and e2e (requires special setup)
|
||||
$packages = go list ./... | Where-Object { $_ -notmatch "/docs$|/e2e$" }
|
||||
go test -short $packages 2>$null
|
||||
if ($LASTEXITCODE -eq 0) { Write-Host "OK: Tests passed" -ForegroundColor Green }
|
||||
else { Write-Host "WARN: Some tests failed" -ForegroundColor Yellow }
|
||||
|
||||
# 6. Config check
|
||||
Write-Host "`n[6] Config file check..." -ForegroundColor Yellow
|
||||
if (Test-Path "configs/config.yaml") {
|
||||
$content = Get-Content "configs/config.yaml" -Raw
|
||||
if ($content -match "refresh_token_expire:\s*(\d+)d") {
|
||||
Write-Host "FAIL: JWT config uses unsupported 'd' unit" -ForegroundColor Red
|
||||
$hours = [int]$matches[1] * 24
|
||||
$content = $content -replace "refresh_token_expire:\s*\d+d", "refresh_token_expire: ${hours}h"
|
||||
Set-Content "configs/config.yaml" -Value $content -NoNewline
|
||||
Write-Host "FIXED: Changed to ${hours}h" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "OK: Config correct" -ForegroundColor Green
|
||||
}
|
||||
} else {
|
||||
Write-Host "WARN: Config file missing" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`n======================================" -ForegroundColor Cyan
|
||||
Write-Host " Validation Complete" -ForegroundColor Cyan
|
||||
Write-Host "======================================" -ForegroundColor Cyan
|
||||
Reference in New Issue
Block a user