Public/Test-AACGoLiveReadiness.ps1

function Test-AACGoLiveReadiness {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        A go-live gate: runs the Well-Architected assessment on a workload and
        says Ready or Not ready - each pillar's score against a minimum, no
        high-severity findings, the checks that must pass - with the blockers,
        a Spectre.Console view, and CSV, PDF and HTML reports. -FailOnGap
        fails a pipeline stage when it isn't ready.
    .DESCRIPTION
        The same read and scores as Invoke-AACWafAssessment (one Resource
        Graph batch, Advisor, Defender for Cloud, the questionnaire's answers
        with -AttestationPath). Then the gates, each Pass, Fail or Warning:
          Pillar score each pillar at -MinimumScore (70) or more; a
                             pillar can have its own with -PillarMinimum
                             @{ Security = 80 }
          High findings no failed high-severity catalogue check (Advisor
                             and Defender count through the pillar scores);
                             -AllowHighFinding turns this into a warning
          Required checks every check matching -RequiredCheck (IDs,
                             wildcards) tested and passed everywhere
          Principles covered every design principle assessed, by a check or
                             the questionnaire; a warning unless
                             -RequireAttestation
        Ready when no gate fails.
 
        What you get:
          at the prompt the verdict, the gates and blockers, the heat maps
                           and the findings
          piped onward the AAC.GoLiveReadiness object (Ready, Verdict,
                           Overall, Pillars, Gates, Blockers, Warnings)
          -PassThru the view and the object
          -NoDisplay the object only
        -FailOnGap throws when not ready, after the view and any reports, so
        `pwsh -File` exits non-zero. -CsvPath writes the gates; -HtmlPath and
        -PdfPath the full report with a Go-live tab.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups.
    .PARAMETER ResourceGroupName
        The workload's resource groups.
    .PARAMETER Pillar
        The pillars to gate on (all five by default).
    .PARAMETER MinimumScore
        The lowest pillar score that passes (0 to 100; 70 by default).
    .PARAMETER PillarMinimum
        A minimum per pillar, e.g. @{ Security = 80; 'Cost Optimization' = 50 }.
    .PARAMETER RequiredCheck
        Check IDs that must pass everywhere they apply; wildcards work
        (Get-AACWafCheck lists them).
    .PARAMETER AllowHighFinding
        High-severity findings warn instead of blocking.
    .PARAMETER RequireAttestation
        Every design principle must be assessed - by a check or the
        questionnaire.
    .PARAMETER AttestationPath
        An answered questionnaire (Export-AACWafQuestionnaire).
    .PARAMETER HeatMapBy
        The heat maps' columns: Subscription, ResourceGroup (the default here)
        or ResourceType.
    .PARAMETER ExcludeCheck
        Check IDs to leave out; wildcards work.
    .PARAMETER NoAdvisor
        Leave out Azure Advisor.
    .PARAMETER NoDefender
        Leave out Defender for Cloud.
    .PARAMETER FailOnGap
        Throw a terminating error when not ready (for pipelines).
    .PARAMETER CsvPath
        Write the gates to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the readiness.
    .PARAMETER NoDisplay
        Return the readiness without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Test-AACGoLiveReadiness -ResourceGroupName 'rg-app-prod'
        Is the workload ready to go live, and if not, what blocks it.
    .EXAMPLE
        Test-AACGoLiveReadiness -ResourceGroupName 'rg-app-prod' -PillarMinimum @{ Security = 85 } -RequiredCheck 'RE-02', 'SE-1*' -AttestationPath .\waf-answers.csv -RequireAttestation -PdfPath .\GoLive.pdf
        A strict gate with the team's answers, as a PDF for sign-off.
    .EXAMPLE
        Test-AACGoLiveReadiness -ResourceGroupName $env:WORKLOAD_RG -NoDisplay -FailOnGap -HtmlPath "$env:BUILD_ARTIFACTSTAGINGDIRECTORY/golive.html"
        In a pipeline: the stage fails when the workload isn't ready, and the report is kept as an artifact.
    .OUTPUTS
        AAC.GoLiveReadiness (piped onward, or with -PassThru or -NoDisplay)
    #>

    [CmdletBinding()]
    [OutputType('AAC.GoLiveReadiness')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ManagementGroupId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [ValidateSet('Reliability', 'Security', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency')]
        [string[]] $Pillar = @('Reliability', 'Security', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency'),

        [ValidateRange(0, 100)]
        [int] $MinimumScore = 70,

        [hashtable] $PillarMinimum = @{},

        [SupportsWildcards()]
        [string[]] $RequiredCheck,

        [switch] $AllowHighFinding,

        [switch] $RequireAttestation,

        [string] $AttestationPath,

        [ValidateSet('Subscription', 'ResourceGroup', 'ResourceType')]
        [string] $HeatMapBy = 'ResourceGroup',

        [SupportsWildcards()]
        [string[]] $ExcludeCheck,

        [switch] $NoAdvisor,

        [switch] $NoDefender,

        [switch] $FailOnGap,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Go-live readiness',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) }

    $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength
    $interactive = -not $NoDisplay -and -not $pipedOnward
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    foreach ($key in @($PillarMinimum.Keys)) {
        if ($key -notin $Pillar -and $key -notin 'Reliability', 'Security', 'Cost Optimization', 'Operational Excellence', 'Performance Efficiency') { throw "Azure Admin Console doesn't know the pillar '$key' in -PillarMinimum: use Reliability, Security, 'Cost Optimization', 'Operational Excellence' or 'Performance Efficiency'." }
    }
    $request = @{
        SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ })
        Pillar = @($Pillar | Select-Object -Unique); HeatMapBy = $HeatMapBy; ExcludeCheck = @($ExcludeCheck | Where-Object { $_ }); NoAdvisor = [bool]$NoAdvisor; NoDefender = [bool]$NoDefender
        AttestationPath = & $resolve $AttestationPath; HistoryPath = ''; CostTag = @('CostCenter', 'CostCentre', 'Cost Center', 'Project', 'Application', 'BillingCode'); OwnerTag = @('Owner', 'Contact', 'TechnicalOwner', 'BusinessOwner'); KeepDuplicates = $false
    }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Go-live readiness' -Color 'mediumpurple2' }
    $state = Get-AACWafAssessment -Request $request
    $result = $state.Result

    # --- The gates ---------------------------------------------------------------------------------------------
    $gates = [System.Collections.Generic.List[object]]::new()
    $gate = { param([string] $Name, [string] $Requirement, [string] $Actual, [string] $Status, [string] $Pillar = '') $gates.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.GoLiveGate'; Gate = $Name; Pillar = $Pillar; Requirement = $Requirement; Actual = $Actual; Status = $Status }) }
    foreach ($p in $result.Pillars) {
        $minimum = if ($PillarMinimum.Contains($p.Pillar)) { [double]$PillarMinimum[$p.Pillar] } else { [double]$MinimumScore }
        if ($null -eq $p.Score) { & $gate 'Pillar score' "$minimum or more" 'not assessed' 'Warning' $p.Pillar }
        else { & $gate 'Pillar score' "$minimum or more" ('{0:N0} ({1})' -f $p.Score, $p.Maturity) $(if ($p.Score -ge $minimum) { 'Pass' } else { 'Fail' }) $p.Pillar }
    }
    $high = @($result.Results | Where-Object { $_.Status -eq 'Fail' -and $_.Severity -eq 'High' -and $_.Source -eq 'Catalogue' })
    $highChecks = @($high | ForEach-Object { $_.CheckId } | Select-Object -Unique)
    & $gate 'High-severity findings' 'none' $(if ($high.Count) { "$($high.Count) on $($highChecks.Count) check(s): $(($highChecks | Select-Object -First 6) -join ', ')" } else { 'none' }) $(if (-not $high.Count) { 'Pass' } elseif ($AllowHighFinding) { 'Warning' } else { 'Fail' })
    foreach ($pattern in @($RequiredCheck | Where-Object { $_ })) {
        $matched = @($result.Checks | Where-Object { $_.CheckId -like $pattern })
        if (-not @($matched | Where-Object Evaluated).Count) { & $gate 'Required check' "$pattern passes" 'not assessed: nothing in scope to test' 'Warning'; continue }
        $failing = @($matched | Where-Object Failed)
        & $gate 'Required check' "$pattern passes" $(if ($failing.Count) { (@($failing | ForEach-Object { "$($_.CheckId) $($_.Failed) failed" }) -join '; ') } else { 'passed' }) $(if ($failing.Count) { 'Fail' } else { 'Pass' })
    }
    $uncovered = @($result.Principles | Where-Object { $null -eq $_.Score })
    & $gate 'Principles covered' 'every design principle assessed (a check or the questionnaire)' $(if ($uncovered.Count) { "$($uncovered.Count) not assessed: $(($uncovered | ForEach-Object { $_.Principle } | Select-Object -First 5) -join '; ')$(if ($uncovered.Count -gt 5) { '; ...' })" } else { 'all assessed' }) $(if (-not $uncovered.Count) { 'Pass' } elseif ($RequireAttestation) { 'Fail' } else { 'Warning' })

    $blockers = @($gates | Where-Object Status -EQ 'Fail' | ForEach-Object { "$($_.Gate)$(if ($_.Pillar) { " ($($_.Pillar))" }): $($_.Actual)" })
    $warnings = @($gates | Where-Object Status -EQ 'Warning' | ForEach-Object { "$($_.Gate)$(if ($_.Pillar) { " ($($_.Pillar))" }): $($_.Actual)" })
    $ready = -not $blockers.Count
    $readiness = [pscustomobject][ordered]@{
        PSTypeName = 'AAC.GoLiveReadiness'
        Ready      = $ready
        Verdict    = $(if ($ready) { 'Ready' } else { 'Not ready' })
        Scope      = $state.Scope.Label
        Overall    = $result.Overall
        Pillars    = @($result.Pillars)
        Gates      = $gates.ToArray()
        Blockers   = $blockers
        Warnings   = $warnings
        Findings   = @($result.Results | Where-Object Status -In 'Fail', 'Partly')
    }

    # --- The report: the assessment's, with the verdict and a Go-live tab first ----------------------------------
    $report = ConvertTo-AACWafReport -State $state -Request $request
    $report.Subtitle = "Go-live readiness: $($request.Pillar -join ', ')"
    $report.Status = $(if ($ready -and -not $warnings.Count) { 'Success' } elseif ($ready) { 'Warning' } else { 'Failed' })
    $report.Headline = $(if ($ready) { "Ready to go live$(if ($warnings.Count) { " - $($warnings.Count) warning(s)" }). $($report.Headline)" } else { "Not ready: $($blockers.Count) blocker(s) - $($blockers[0])$(if ($blockers.Count -gt 1) { '; ...' })" })
    $report.Facts['Minimum score'] = "$MinimumScore$(if ($PillarMinimum.Count) { " ($(@($PillarMinimum.Keys | ForEach-Object { "$_ $($PillarMinimum[$_])" }) -join ', '))" })"
    $report.Tiles = @(@{ Value = $readiness.Verdict; Label = 'go-live verdict'; Tone = $(if ($ready) { 'good' } else { 'bad' }); Table = 'gates' }) + @($report.Tiles)
    $report.Tabs = @(@{ Name = 'Go-live'; Badge = $blockers.Count; Tone = $(if ($ready) { 'good' } else { 'bad' }); Note = 'Each gate: what it requires, what was found, and whether it passes. Ready when no gate fails.' }) + @($report.Tabs)
    $statusTones = @{ Pass = 'good'; Fail = 'bad'; Warning = 'warn' }
    $report.Tables = @(@{ Id = 'gates'; Title = 'Go-live gates'; Section = 'Go-live'; Rows = $gates.ToArray(); Noun = 'gates'; GroupBy = @('Status', 'Gate'); ConsoleLimit = 20
            Columns = @(
                @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $statusTones; Facet = $true; Console = $true; Pdf = $true }
                @{ Key = 'Gate'; Label = 'Gate'; Facet = $true; Console = $true; Pdf = $true }
                @{ Key = 'Pillar'; Label = 'Pillar'; Facet = $true; Console = $true; Pdf = $true }
                @{ Key = 'Requirement'; Label = 'Requires'; Console = $true; Pdf = $true }
                @{ Key = 'Actual'; Label = 'Found'; Type = 'wide'; Console = $true; Pdf = $true }
            ) }) + @($report.Tables)
    $report.Hint = '-MinimumScore, -PillarMinimum and -RequiredCheck set the bar; -AttestationPath adds the team''s answers; -FailOnGap fails a pipeline; -HtmlPath, -PdfPath or -CsvPath for a report.'
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $gates.ToArray() -Noun 'gate' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object @($readiness) -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
    if ($FailOnGap -and -not $ready) {
        $problem = [System.InvalidOperationException]::new("Azure Admin Console: the workload isn't ready to go live - $($blockers.Count) blocker(s): $($blockers -join ' | ')")
        $problem.Data['AACHint'] = 'Fix the blockers (the Go-live gates in the report list them), or adjust -MinimumScore, -PillarMinimum or -RequiredCheck if the bar is wrong for this workload.'
        throw $problem
    }
}