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 } 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-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" try { Push-Location $OutputPath Start-WARACollector -TenantID $TenantId -SubscriptionIds $subArg -ErrorAction Stop if (Get-Command Start-WARAAnalyzer -ErrorAction SilentlyContinue) { Start-WARAAnalyzer -TenantID $TenantId -SubscriptionIds $subArg -ErrorAction Stop } Pop-Location } catch { Pop-Location Write-Warning "WARA collector failed: $(Remove-Credentials -Text ([string]$_)). Returning empty result." return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Failed'; Message = (Remove-Credentials -Text ([string]$_)); Findings = @(); Errors = @() } } # Find the newest JSON output file $jsonFile = Get-ChildItem -Path $OutputPath -Filter "WARA_File_*.json" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if (-not $jsonFile) { Write-Warning "WARA collector ran but no output JSON found in $OutputPath." return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; Status = 'Failed'; Message = 'No output JSON produced'; Findings = @(); Errors = @() } } # 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-ChildItem -Path $OutputPath -Filter "Expert-Analysis-*.xlsx" | Sort-Object LastWriteTime -Descending | Select-Object -First 1 $workbookMetadata = if ($xlsxFile) { Get-WaraWorkbookMetadata -WorkbookPath $xlsxFile.FullName } else { @{} } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() $recommendations = $raw.Recommendations ?? ($raw.PSObject.Properties.Value | Where-Object { $_ -is [array] } | Select-Object -First 1) 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')) 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 = @($rec.ImpactedResources) 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 }) } } return [PSCustomObject]@{ SchemaVersion = '1.0'; Source = 'wara'; ToolVersion = $toolVersion; Status = 'Success'; Message = ''; Findings = @($findings); Errors = @() } |