Private/Invoke-AACPSRuleEngine.ps1

function Invoke-AACPSRuleEngine {
    <#
    .SYNOPSIS
        Reads the estate and runs PSRule for Azure - and the module's and
        your custom rules - on it; returns one AAC.PSRuleResult per rule and
        resource. The engine behind Invoke-AACPSRule.
    .DESCRIPTION
        1. Get-AACRuleData reads the estate in Export-AzRuleData's shape with
           the Connect-AAC sign-in (Resource Graph plus the child settings
           PSRule's rules read).
        2. PSRule\PSRuleRunner.ps1 runs the rules in a pwsh process of its
           own (PSRule's YamlDotNet.dll must not meet another version of it
           in this session): PSRule for Azure's, the module's own
           (PSRule\Rules) and any -RulePath files, narrowed by -Rule and
           -ExcludeRule (names or wildcards).
        3. Each result becomes an AAC.PSRuleResult: the rule (name, title,
           Well-Architected pillar, severity, reference, documentation link,
           recommendation, source), the resource (name, type, group,
           subscription, ID) and the outcome with its reasons.
 
        Progress goes to the Invoke-AACProgress display (psrule-read,
        psrule-expand, psrule-rules). Returns @{ Results; Warnings; Rules;
        Objects }.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    # $failure is assigned inside the ForEach-Object script block that reads
    # the runner's output, and read after it - the analyzer can't see that.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'failure', Justification = 'Assigned in a ForEach-Object script block, read after it.')]
    param(
        [string[]] $SubscriptionId = @(),
        [string[]] $ResourceType = @(),
        [string[]] $Rule = @(),
        [string[]] $ExcludeRule = @(),
        [string] $Baseline,
        [hashtable] $Configuration = @{},
        [string[]] $RulePath = @()
    )

    $installed = Get-Module -Name 'PSRule.Rules.Azure' -ListAvailable | Sort-Object -Property Version -Descending | Select-Object -First 1
    if (-not $installed) {
        throw 'PSRule for Azure is not installed. Run: Install-PSResource PSRule.Rules.Azure -Scope CurrentUser'
    }

    # --- Read the estate, as Export-AzRuleData would ------------------------------------
    $data = Get-AACRuleData -SubscriptionId $SubscriptionId -ResourceType $ResourceType

    # PSRule's PowerShell rules need objects, and the estate goes to the
    # runner as JSON. Keys differing only by case ('Owner' and 'owner'
    # tags) would make that JSON unreadable, so each resource is first made
    # an object, which keeps just the first of them - quickly by a JSON round
    # trip, or exactly when the round trip refuses the duplicate keys.
    $objects = @(foreach ($resource in $data.Resources) {
            try {
                ConvertTo-Json -InputObject $resource -Depth 100 -Compress | ConvertFrom-Json -Depth 100 -ErrorAction Stop
            }
            catch {
                ConvertTo-AACPSObject -InputObject $resource
            }
        })

    # --- Run the rules, in a process of their own --------------------------------------------
    $paths = @(Join-Path -Path $script:AACModuleRoot -ChildPath 'PSRule/Rules') + @($RulePath)
    $work = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "aac-psrule-$([guid]::NewGuid().ToString('n'))"
    New-Item -ItemType Directory -Path $work -Force | Out-Null
    $ruleCount = 0
    try {
        $inputFile = Join-Path -Path $work -ChildPath 'input.json'
        $settingFile = Join-Path -Path $work -ChildPath 'settings.json'
        $outputFile = Join-Path -Path $work -ChildPath 'results.json'
        [System.IO.File]::WriteAllText($inputFile, (ConvertTo-Json -InputObject $objects -Depth 100 -Compress), [System.Text.UTF8Encoding]::new($false))
        $settings = @{ Rule = @($Rule); ExcludeRule = @($ExcludeRule); Baseline = $Baseline; Configuration = $Configuration; RulePath = $paths }
        [System.IO.File]::WriteAllText($settingFile, (ConvertTo-Json -InputObject $settings -Depth 20), [System.Text.UTF8Encoding]::new($false))

        Update-AACProgress -Id 'psrule-rules' -Total ([Math]::Max(1, $objects.Count)) -Description "Loading PSRule for Azure $($installed.Version)"
        # The same pwsh as this session.
        $pwsh = (Get-Process -Id $PID).Path
        $runner = Join-Path -Path $script:AACModuleRoot -ChildPath 'PSRule/PSRuleRunner.ps1'
        $failure = $null
        $other = [System.Collections.Generic.List[string]]::new()
        $done = 0
        & $pwsh -NoProfile -NonInteractive -File $runner -ModulePath $installed.Path -InputPath $inputFile -SettingPath $settingFile -OutputPath $outputFile 2>&1 | ForEach-Object {
            $line = [string]$_
            if ($line -match '^PROGRESS (\d+)$') {
                $now = [int]$Matches[1]
                if ($now -gt $done) {
                    Update-AACProgress -Id 'psrule-rules' -Increment ($now - $done)
                    $done = $now
                }
            }
            elseif ($line -match '^RULES (\d+)$') {
                $ruleCount = [int]$Matches[1]
                Update-AACProgress -Id 'psrule-rules' -Description ('Running {0:N0} rules on {1:N0} objects' -f $ruleCount, $objects.Count)
            }
            elseif ($line -match '^ERROR (.*)$') {
                $failure = $Matches[1]
            }
            elseif ($line.Trim()) {
                $other.Add($line)
            }
        }
        $exitCode = $LASTEXITCODE
        if ($failure -or $exitCode -ne 0 -or -not (Test-Path -LiteralPath $outputFile)) {
            $reason = if ($failure) { $failure } elseif ($other.Count) { (@($other) | Select-Object -Last 5) -join ' ' } else { "PowerShell exited with code $exitCode" }
            throw "PSRule for Azure $($installed.Version) failed: $reason"
        }
        $raw = @(Get-Content -LiteralPath $outputFile -Raw | ConvertFrom-Json -Depth 10)
    }
    finally {
        Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction Ignore
    }

    # --- Results as objects --------------------------------------------------------------------
    $pillarOrder = @('Security', 'Reliability', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency')
    $outcomeOrder = @{ Fail = 0; Error = 1; Pass = 2 }
    $names = $data.SubscriptionNames
    $results = @(foreach ($item in $raw) {
            $name = [string]$item.RuleName
            $source = if ($item.ModuleName -eq 'PSRule.Rules.Azure') { 'PSRule for Azure' } elseif ($name -like 'AAC.*') { 'Azure.Admin.Console' } else { 'Custom' }
            $link = if ($item.Link) { [string]$item.Link } elseif ($source -eq 'PSRule for Azure') { "https://azure.github.io/PSRule.Rules.Azure/en/rules/$name/" } else { '' }
            $type = [string]$item.Type
            $subscriptionKey = [string]$item.SubscriptionId
            $reasons = @($item.Reason | Where-Object { $_ })
            if ([string]$item.Outcome -eq 'Error' -and $item.ErrorMessage) { $reasons = @("The rule could not be evaluated: $($item.ErrorMessage)") }
            [pscustomobject]@{
                PSTypeName       = 'AAC.PSRuleResult'
                Outcome          = [string]$item.Outcome
                Pillar           = $(if ($item.Pillar) { [string]$item.Pillar } else { 'Other' })
                RuleName         = $name
                Title            = $(if ($item.DisplayName -and $item.DisplayName -ne $name) { [string]$item.DisplayName } else { [string]$item.Synopsis })
                Severity         = $(if ($item.Severity) { [string]$item.Severity } else { [string]$item.Level })
                ResourceName     = $(if ($item.TargetName) { [string]$item.TargetName } else { [string]$item.Name })
                ResourceType     = $type.ToLowerInvariant()
                ResourceGroup    = $(if ($item.ResourceGroupName) { [string]$item.ResourceGroupName } elseif ($type -eq 'Microsoft.Subscription') { '' } else { '' })
                SubscriptionName = $(if ($subscriptionKey -and $names.ContainsKey($subscriptionKey)) { $names[$subscriptionKey] } else { $subscriptionKey })
                SubscriptionId   = $subscriptionKey
                Reason           = $reasons -join '; '
                Recommendation   = [string]$item.Recommendation
                Synopsis         = [string]$item.Synopsis
                Ref              = [string]$item.Ref
                Link             = $link
                Source           = $source
                ResourceId       = [string]$item.Id
            }
        })
    $results = @($results | Sort-Object -Property @(
            @{ Expression = { $i = $pillarOrder.IndexOf($_.Pillar); if ($i -lt 0) { 99 } else { $i } } }
            @{ Expression = { $outcomeOrder[$_.Outcome] } }
            'RuleName', 'SubscriptionName', 'ResourceGroup', 'ResourceName'
        ))
    $failed = @($results | Where-Object Outcome -NE 'Pass').Count
    Update-AACProgress -Id 'psrule-rules' -Complete -Description ('PSRule for Azure {0}: {1:N0} rules, {2:N0} results - {3:N0} passed, {4:N0} failed' -f $installed.Version, $ruleCount, $results.Count, ($results.Count - $failed), $failed)

    @{
        Results  = $results
        Warnings = @($data.Warnings)
        Rules    = $ruleCount
        Objects  = $objects.Count
        Version  = [string]$installed.Version
    }
}