modules/Invoke-WARA.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for the Well-Architected Reliability Assessment (WARA) collector. .DESCRIPTION Installs/imports the WARA module if needed, runs Start-WARACollector for the given subscription, parses the output JSON, and returns findings as PSObjects. Gracefully degrades if WARA is not available or collector fails. .PARAMETER SubscriptionId Azure subscription ID (without /subscriptions/ prefix). .PARAMETER TenantId Azure tenant ID. Defaults to current Az context tenant if not specified. .PARAMETER OutputPath Directory to write WARA collector JSON. Defaults to .\output\wara. .EXAMPLE .\Invoke-WARA.ps1 -SubscriptionId "00000000-0000-0000-0000-000000000000" #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $SubscriptionId, [string] $TenantId, [string] $OutputPath = (Join-Path $PSScriptRoot '..\output\wara') ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } $missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1' if (Test-Path $missingToolPath) { . $missingToolPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } $retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason; if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" }; return $line } } if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param([Parameter(Mandatory)][scriptblock]$ScriptBlock,[int]$MaxAttempts=1,[int]$InitialDelaySeconds=0,[string[]]$TransientMessagePatterns=@()) return & $ScriptBlock } } $aprlCatalogPath = Join-Path $PSScriptRoot 'shared' 'AprlCatalog.ps1' if (Test-Path $aprlCatalogPath) { . $aprlCatalogPath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command Write-MissingToolNotice -ErrorAction SilentlyContinue)) { function Write-MissingToolNotice { param([string]$Tool, [string]$Message) Write-Warning $Message } } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } function Get-WaraPropertyValue { param( [Parameter(Mandatory)][object] $Object, [Parameter(Mandatory)][string[]] $Names ) foreach ($name in $Names) { if ($Object -and $Object.PSObject.Properties[$name]) { $value = $Object.$name if ($null -ne $value -and -not [string]::IsNullOrWhiteSpace([string]$value)) { return $value } } } return $null } function Normalize-WaraPillar { param([string] $Value) if ([string]::IsNullOrWhiteSpace($Value)) { return '' } $normalized = $Value.Trim().ToLowerInvariant() if ($normalized -match 'reliab') { return 'Reliability' } if ($normalized -match 'secur') { return 'Security' } if ($normalized -match 'cost') { return 'Cost' } if ($normalized -match 'perform') { return 'Performance' } if ($normalized -match 'operat') { return 'Operational' } return '' } function New-WaraKey { param([object] $Value) if ($null -eq $Value) { return '' } $key = [string]$Value if ([string]::IsNullOrWhiteSpace($key)) { return '' } return $key.Trim().ToLowerInvariant() } function Get-WaraFreshArtifact { <# .SYNOPSIS Return the newest file matching Filter that was produced by the current run. .DESCRIPTION output/ is never cleaned between runs, so an artifact left behind by an earlier successful scan is otherwise indistinguishable from one the collector just wrote. Known holds the FullName -> LastWriteTimeUtc of every file that existed before the run started; an artifact counts as fresh when it is absent from that snapshot or its timestamp has moved, which also covers the collector overwriting the same filename when it runs twice within the same minute. #> param( [Parameter(Mandatory)] [string] $Path, [Parameter(Mandatory)] [string] $Filter, [Parameter(Mandatory)] [hashtable] $Known ) return Get-ChildItem -Path $Path -Filter $Filter -File -ErrorAction SilentlyContinue | Where-Object { -not $Known.ContainsKey($_.FullName) -or $Known[$_.FullName] -ne $_.LastWriteTimeUtc } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 } function Get-WaraWorkbookMetadata { param([string] $WorkbookPath) $metadata = @{} if ([string]::IsNullOrWhiteSpace($WorkbookPath)) { return $metadata } if (-not (Test-Path $WorkbookPath)) { return $metadata } if (-not (Get-Command Import-Excel -ErrorAction SilentlyContinue)) { return $metadata } try { $sheets = @('Action Plan', 'ActionPlan', 'Recommendations') foreach ($sheet in $sheets) { try { $rows = @(Import-Excel -Path $WorkbookPath -WorksheetName $sheet -ErrorAction Stop) } catch { continue } foreach ($row in $rows) { $recId = Get-WaraPropertyValue -Object $row -Names @('Recommendation Id', 'RecommendationId', 'GUID', 'Recommendation GUID') $title = Get-WaraPropertyValue -Object $row -Names @('Recommendation', 'Title') $pillar = Normalize-WaraPillar ([string](Get-WaraPropertyValue -Object $row -Names @('Pillar', 'Recommendation Control', 'RecommendationControl'))) $entry = [PSCustomObject]@{ Pillar = $pillar PotentialBenefit = [string](Get-WaraPropertyValue -Object $row -Names @('Potential Benefit', 'PotentialBenefit')) Status = [string](Get-WaraPropertyValue -Object $row -Names @('Status', 'Recommendation Status')) Impact = [string](Get-WaraPropertyValue -Object $row -Names @('Impact')) Effort = [string](Get-WaraPropertyValue -Object $row -Names @('Effort')) ServiceCategory = [string](Get-WaraPropertyValue -Object $row -Names @('Service Category', 'ServiceCategory', 'Service')) DeepLinkUrl = [string](Get-WaraPropertyValue -Object $row -Names @('Learn More', 'LearnMoreLink', 'DeepLinkUrl', 'Link')) RemediationSteps = @((Get-WaraPropertyValue -Object $row -Names @('Remediation Steps', 'Remediation', 'Action Plan')) -split "(`r`n|`n|;)" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } foreach ($key in @((New-WaraKey $recId), (New-WaraKey $title))) { if (-not [string]::IsNullOrWhiteSpace($key) -and -not $metadata.ContainsKey($key)) { $metadata[$key] = $entry } } } } } catch { Write-Warning "Failed to parse WARA workbook metadata: $(Remove-Credentials -Text ([string]$_))" } return $metadata } # Check WARA module is available (centralized Install-Prerequisites handles installation) $waraModule = @(Get-Module -ListAvailable -Name WARA | Sort-Object Version -Descending | Select-Object -First 1) if (-not $waraModule) { Write-MissingToolNotice -Tool 'wara' -Message "WARA module not found. Install with: Install-Module WARA -Scope CurrentUser" return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Skipped'; Message = 'WARA module not installed. Run: Install-Module WARA -Scope CurrentUser'; Findings = @(); Errors = @() } } $toolVersion = [string]$waraModule[0].Version Import-Module WARA -ErrorAction SilentlyContinue if (-not (Get-Command Start-WARACollector -ErrorAction SilentlyContinue)) { Write-MissingToolNotice -Tool 'wara' -Message "WARA module loaded but Start-WARACollector not found. Returning empty result." return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Skipped'; Message = 'Could not install WARA module'; Findings = @(); Errors = @() } } # Resolve tenant if (-not $TenantId) { # Probe Az context (SilentlyContinue: probing for sign-in state, handled by null check below) $ctx = Get-AzContext -ErrorAction SilentlyContinue $TenantId = if ($null -ne $ctx -and $null -ne $ctx.Tenant) { $ctx.Tenant.Id } else { $null } if (-not $TenantId) { Write-Warning "No TenantId provided and no Az context found. Returning empty result." return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Failed'; Message = 'No TenantId and no Az context'; Findings = @(); Errors = @() } } } # Ensure output dir if (-not (Test-Path $OutputPath)) { $null = New-Item -ItemType Directory -Path $OutputPath -Force } # Run collector $subArg = "/subscriptions/$SubscriptionId" # Snapshot what is already on disk before the collector runs. See Get-WaraFreshArtifact: # without this, a stale WARA-File-*.json from an earlier scan makes a total collector # failure look like a success and last run's findings get re-reported as current. $knownArtifacts = @{} foreach ($existing in @(Get-ChildItem -Path $OutputPath -File -ErrorAction SilentlyContinue)) { $knownArtifacts[$existing.FullName] = $existing.LastWriteTimeUtc } # Azure Advisor occasionally returns a transient GatewayTimeout, which surfaces inside the # WARA module as "Cannot bind argument to parameter 'AdvisorMetadata' because it is null". # That string matches none of the shared transient patterns, so the retry conditions are # passed explicitly. A collector run that produces no file is also retried: the collector # writes its JSON even when a subscription has no impacted resources, so a missing file # means the run failed rather than that there was nothing to report. $collectorError = $null $collectorJson = $null try { $collectorJson = Invoke-WithRetry -MaxAttempts 3 -InitialDelaySeconds 15 -TransientMessagePatterns @( 'AdvisorMetadata', 'produced no collector output', '\b429\b', '\b503\b', '\b504\b', '\b408\b', 'throttl', 'rate limit', 'timed out', 'timeout', 'service unavailable', 'temporarily unavailable', 'connection reset' ) -ScriptBlock { Push-Location $OutputPath try { # Tolerate per-resource "No recommendation found" errors so a single unmapped # resource type does not abort collection for the whole subscription. Start-WARACollector -TenantID $TenantId -SubscriptionIds $subArg -ErrorAction Continue } finally { Pop-Location } $fresh = Get-WaraFreshArtifact -Path $OutputPath -Filter 'WARA*File*.json' -Known $knownArtifacts if (-not $fresh) { throw (Format-FindingErrorMessage (New-FindingError ` -Source 'wrapper:wara' ` -Category 'TransientFailure' ` -Reason "Start-WARACollector produced no collector output for subscription '$SubscriptionId'." ` -Remediation 'Re-run the scan. If it keeps failing, run Start-WARACollector directly to see the underlying Azure Advisor or Resource Graph error.')) } return $fresh } } catch { $collectorError = $_ } if (-not $collectorJson) { $message = if ($collectorError) { Remove-Credentials -Text ([string]$collectorError) } else { 'No output JSON produced' } Write-Warning "WARA collector failed: $message. Returning empty result." return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Failed'; Message = $message; Findings = @(); Errors = @() } } # Analyzer (v1.x) takes -JSONFile (the collector output), not -TenantID/-SubscriptionIds. if (Get-Command Start-WARAAnalyzer -ErrorAction SilentlyContinue) { try { Push-Location $OutputPath try { Start-WARAAnalyzer -JSONFile $collectorJson.FullName -ErrorAction Stop } finally { Pop-Location } } catch { Write-Warning "WARA analyzer step failed (collector data retained): $(Remove-Credentials -Text ([string]$_))" } } $jsonFile = $collectorJson # Parse findings try { $raw = Get-Content $jsonFile.FullName -Raw | ConvertFrom-Json -ErrorAction Stop } catch { Write-Warning "Could not parse WARA JSON: $(Remove-Credentials -Text ([string]$_))" return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Failed'; Message = (Remove-Credentials -Text "JSON parse error: $([string]$_)"); Findings = @(); Errors = @() } } $xlsxFile = Get-WaraFreshArtifact -Path $OutputPath -Filter 'Expert-Analysis-*.xlsx' -Known $knownArtifacts $workbookMetadata = if ($xlsxFile) { Get-WaraWorkbookMetadata -WorkbookPath $xlsxFile.FullName } else { @{} } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() $recommendations = if ($raw.PSObject.Properties['Recommendations'] -and $raw.Recommendations) { $raw.Recommendations } else { # WARA collector v2.x exposes reliability findings across two arrays: # - 'impactedResources' : APRL query results (per-resource; no impact level in JSON) # - 'advisory' : Azure Advisor results (per-resource; carries Impact + Description) # Merge both so the assessment includes Advisor's real severities and descriptions. $combined = [System.Collections.Generic.List[object]]::new() if ($raw.PSObject.Properties['impactedResources'] -and $raw.impactedResources) { foreach ($r in @($raw.impactedResources)) { $combined.Add($r) } } if ($raw.PSObject.Properties['advisory'] -and $raw.advisory) { foreach ($r in @($raw.advisory)) { $combined.Add($r) } } if ($combined.Count -eq 0) { # Generic fallback: first non-empty array property. Iterate the Properties collection # directly. Piping $raw.PSObject.Properties.Value unrolls nested arrays so a # Where-Object { $_ -is [array] } filter never matches the array as a whole. foreach ($p in $raw.PSObject.Properties) { if ($p.Value -is [System.Array] -and @($p.Value).Count -gt 0) { foreach ($r in @($p.Value)) { $combined.Add($r) } break } } } $combined } foreach ($rec in $recommendations) { $recommendationId = [string](Get-WaraPropertyValue -Object $rec -Names @('RecommendationId', 'GUID', 'Id')) if ([string]::IsNullOrWhiteSpace($recommendationId)) { $recommendationId = [guid]::NewGuid().ToString() } $title = [string](Get-WaraPropertyValue -Object $rec -Names @('Recommendation', 'Title', 'Description')) if ([string]::IsNullOrWhiteSpace($title)) { $title = 'Unknown' } $metadata = $null foreach ($key in @((New-WaraKey $recommendationId), (New-WaraKey $title))) { if (-not [string]::IsNullOrWhiteSpace($key) -and $workbookMetadata.ContainsKey($key)) { $metadata = $workbookMetadata[$key] break } } $impactedResources = if ($rec.PSObject.Properties['ImpactedResources'] -and $rec.ImpactedResources) { @($rec.ImpactedResources) } else { @() } if (-not $impactedResources -or $impactedResources.Count -eq 0) { $fallbackResourceId = [string](Get-WaraPropertyValue -Object $rec -Names @('ResourceId', 'Id')) if (-not [string]::IsNullOrWhiteSpace($fallbackResourceId)) { $impactedResources = @([PSCustomObject]@{ ResourceId = $fallbackResourceId }) } else { $impactedResources = @([PSCustomObject]@{ ResourceId = '' }) } } $entityRefs = [System.Collections.Generic.List[string]]::new() foreach ($resource in $impactedResources) { $candidate = if ($resource -is [string]) { $resource } else { [string](Get-WaraPropertyValue -Object $resource -Names @('ResourceId', 'Id')) } if (-not [string]::IsNullOrWhiteSpace($candidate)) { $entityRefs.Add($candidate) } } $entityRefArray = @($entityRefs | Select-Object -Unique) $pillar = Normalize-WaraPillar ([string](Get-WaraPropertyValue -Object $rec -Names @('Pillar', 'RecommendationControl', 'Category'))) if ([string]::IsNullOrWhiteSpace($pillar) -and $metadata) { $pillar = Normalize-WaraPillar ([string]$metadata.Pillar) } $impact = [string](Get-WaraPropertyValue -Object $rec -Names @('Impact', 'RecommendationImpact')) if ([string]::IsNullOrWhiteSpace($impact) -and $metadata) { $impact = [string]$metadata.Impact } $effort = [string](Get-WaraPropertyValue -Object $rec -Names @('Effort')) if ([string]::IsNullOrWhiteSpace($effort) -and $metadata) { $effort = [string]$metadata.Effort } $serviceCategory = [string](Get-WaraPropertyValue -Object $rec -Names @('ServiceCategory', 'Service')) if ([string]::IsNullOrWhiteSpace($serviceCategory) -and $metadata) { $serviceCategory = [string]$metadata.ServiceCategory } $baselineTags = @() if (-not [string]::IsNullOrWhiteSpace($serviceCategory)) { $baselineTags += "service-category:$serviceCategory" } $deepLink = if ($metadata) { [string]$metadata.DeepLinkUrl } else { '' } if ([string]::IsNullOrWhiteSpace($deepLink)) { $deepLink = [string](Get-WaraPropertyValue -Object $rec -Names @('LearnMoreLink', 'Link', 'DeepLinkUrl')) } $remediation = [string](Get-WaraPropertyValue -Object $rec -Names @('Remediation', 'RecommendationAction')) $remediationSteps = @() if ($rec.PSObject.Properties['Description'] -and $rec.Description -and $rec.Description.PSObject.Properties['Steps']) { $remediationSteps = @($rec.Description.Steps | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } if (($remediationSteps.Count -eq 0) -and $metadata) { $remediationSteps = @($metadata.RemediationSteps | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } if ([string]::IsNullOrWhiteSpace($remediation) -and $remediationSteps.Count -gt 0) { $remediation = ($remediationSteps -join ' ') } $status = [string](Get-WaraPropertyValue -Object $rec -Names @('Status')) if ([string]::IsNullOrWhiteSpace($status) -and $metadata) { $status = [string]$metadata.Status } $potentialBenefit = [string](Get-WaraPropertyValue -Object $rec -Names @('PotentialBenefit', 'Potential Benefit')) if ([string]::IsNullOrWhiteSpace($potentialBenefit) -and $metadata) { $potentialBenefit = [string]$metadata.PotentialBenefit } $frameworks = @(@{ Name = 'WAF' Pillars = if ($pillar) { @($pillar) } else { @() } Controls = @($recommendationId) }) $category = [string](Get-WaraPropertyValue -Object $rec -Names @('Category', 'Service', 'RecommendationControl')) if ([string]::IsNullOrWhiteSpace($category)) { $category = 'Reliability' } $severity = [string](Get-WaraPropertyValue -Object $rec -Names @('Severity', 'Impact')) if ([string]::IsNullOrWhiteSpace($severity)) { $severity = 'Medium' } $detail = [string](Get-WaraPropertyValue -Object $rec -Names @('LongDescription', 'Description')) if ([string]::IsNullOrWhiteSpace($detail) -and $remediationSteps.Count -gt 0) { $detail = $remediationSteps -join ' ' } if ([string]::IsNullOrWhiteSpace($detail)) { $detail = '' } foreach ($resource in $impactedResources) { $resourceId = if ($resource -is [string]) { $resource } else { [string](Get-WaraPropertyValue -Object $resource -Names @('ResourceId', 'Id')) } $resourceId = if ($resourceId) { $resourceId } else { '' } $findingId = "$recommendationId::$resourceId" if ([string]::IsNullOrWhiteSpace($resourceId)) { $findingId = $recommendationId } $findings.Add([PSCustomObject]@{ Id = $findingId RecommendationId = $recommendationId Category = $category Pillar = $pillar Title = $title Severity = $severity Impact = $impact Effort = $effort Compliant = $false Detail = $detail Remediation = $remediation RemediationSteps = @($remediationSteps) ResourceId = [string]$resourceId LearnMoreUrl = $deepLink DeepLinkUrl = $deepLink Frameworks = @($frameworks) BaselineTags = @($baselineTags) ServiceCategory = $serviceCategory EntityRefs = @($entityRefArray) Status = $status PotentialBenefit = $potentialBenefit ToolVersion = $toolVersion }) } } # Best-effort APRL catalog enrichment: recover Title/Severity/Detail/LearnMore # for findings the workbook-metadata join left as 'Unknown', and stamp the APRL # recommendation control as the report Category on every matched finding. # Non-fatal and offline-safe: a missing catalog leaves findings unchanged. # The catalog is consulted whenever there are findings (not only when a Title is # missing) because category enrichment applies to well-formed findings too; # Get-WaraAprlCatalog is cache-backed, so this does not add a fetch per run. if ($findings.Count -gt 0 -and (Get-Command Merge-WaraAprlMetadata -ErrorAction SilentlyContinue)) { try { $catalogCache = if (Get-Command Get-AprlDefaultCachePath -ErrorAction SilentlyContinue) { Get-AprlDefaultCachePath } else { Join-Path ([System.IO.Path]::GetTempPath()) 'wara-aprl-catalog.json' } $aprlCatalog = Get-WaraAprlCatalog -Path $catalogCache if ($aprlCatalog) { $null = Merge-WaraAprlMetadata -Findings $findings -Catalog $aprlCatalog } } catch { Write-Verbose "APRL catalog enrichment skipped: $([string]$_)" } } return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; ToolVersion = $toolVersion; Status = 'Success'; Message = ''; Findings = @($findings); Errors = @() } |