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*(?[^\r\n]+)(?.*?)(?=^\s*-\s*alert:|\z)') $rules = @() foreach ($match in $matches) { $body = $match.Groups['body'].Value $severityMatch = [regex]::Match($body, '(?m)^\s*severity:\s*(?[^\r\n]+)') $forMatch = [regex]::Match($body, '(?m)^\s*for:\s*(?[^\r\n]+)') $exprMatch = [regex]::Match($body, '(?ms)^\s*expr:\s*\|?\s*(?.*?)(?=^\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*''(?[^'']+)''') $routeMatches = [regex]::Matches($Content, '(?ms)^\s*-\s*match:\s*(?.*?)(?=^\s*-\s*match:|^\s*receivers:|\z)') $routes = @() foreach ($match in $routeMatches) { $body = $match.Groups['body'].Value $severityMatch = [regex]::Match($body, '(?m)^\s*severity:\s*(?[^\r\n]+)') $receiverMatch = [regex]::Match($body, '(?m)^\s*receiver:\s*''(?[^'']+)''') $routes += [pscustomobject]@{ Severity = $severityMatch.Groups['severity'].Value.Trim() Receiver = $receiverMatch.Groups['receiver'].Value.Trim() } } $receiverMatches = [regex]::Matches($Content, '(?m)^\s*-\s*name:\s*''(?[^'']+)''') $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*(?[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