docs: project docs, scripts, deployment configs, and evidence

This commit is contained in:
2026-04-02 11:22:17 +08:00
parent 4718980ab5
commit bbeeb63dfa
396 changed files with 165018 additions and 0 deletions

View 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
}

View 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'"

View 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')

View 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

View 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"
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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