Private/ConvertTo-AACTerraformPlan.ps1

function ConvertTo-AACTerraformPlan {
    <#
    .SYNOPSIS
        Flattens a Terraform plan in JSON (terraform show -json) into one row
        per resource change and one row per attribute that changes.
    .DESCRIPTION
        -Plan is the plan read with ConvertFrom-Json -AsHashtable. Returns
        @{ Info; Changes; Attributes; Stats }:
 
          Changes AAC.TerraformChange: one per resource in
                      resource_changes (Source 'Plan'), resource_drift
                      (Source 'Drift': changed outside Terraform) and
                      output_changes (Mode 'output') - with Action Create,
                      Update, Replace, Delete, Read, Import, Move, Forget or
                      NoOp, its Azure name, resource group, location and ID,
                      why (action_reason), and its attribute changes
          Attributes (on each change) AAC.TerraformAttributeChange: one
                      per changed attribute, flattened to a path -
                        tags["cost.centre"] a map key
                        site_config[0].always_on
                        security_rule[name=ssh].destination_port_range
                                              a list of blocks, an element
                                              named by its 'name'
                        policy_rule{json}.then.effect
                                              inside a JSON-encoded string
                      with Before, After, Change (Added, Removed, Modified,
                      Known after apply, Reordered) and ForcesReplacement
                      (from replace_paths)
 
        Updates and replacements list only what changes; a create lists
        every attribute set (and those known after apply), a delete every
        attribute the object had. Elements of a list of blocks are paired
        first by being identical, then by name, then in order, so a rule
        added to a set doesn't show every rule after it as changed.
 
        Sensitive values are never returned: the plan JSON holds them in
        clear text, marked in before_sensitive and after_sensitive; they
        come back as '(sensitive)'.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)]
        [System.Collections.IDictionary] $Plan,

        [string] $Path,

        # Called with (done, total) every 25 resources, for a progress line.
        [scriptblock] $OnProgress
    )

    $actionOrder = @{ Delete = 0; Replace = 1; Update = 2; Create = 3; Import = 4; Move = 5; Read = 6; Forget = 7; NoOp = 8 }
    $reasons = @{
        replace_because_tainted            = 'tainted (a create or update failed, or terraform taint)'
        replace_because_cannot_update      = 'a changed attribute forces replacement'
        replace_by_request                 = 'replacement requested (-replace)'
        replace_by_triggers                = 'something in its replace_triggered_by changed'
        delete_because_no_resource_config  = 'removed from the configuration'
        delete_because_no_module           = 'its module was removed from the configuration'
        delete_because_wrong_repetition    = 'count or for_each was added or removed'
        delete_because_count_index         = 'its count index is out of range'
        delete_because_each_key            = 'its for_each key is gone'
        delete_because_no_move_target      = 'the target of its moved block is not in the configuration'
        read_because_config_unknown        = 'its configuration depends on values known only after apply'
        read_because_dependency_pending    = 'it depends on resources with changes pending'
        read_because_check_nested          = 'a check block reads it'
    }

    # The values themselves are walked in C# (Import-AACTerraformPlanFlattener).
    Import-AACTerraformPlanFlattener

    function Get-Action([object[]] $Actions) {
        switch ($Actions -join ',') {
            'no-op' { 'NoOp' }
            'create' { 'Create' }
            'read' { 'Read' }
            'update' { 'Update' }
            'delete' { 'Delete' }
            'delete,create' { 'Replace' }
            'create,delete' { 'Replace' }
            'forget' { 'Forget' }
            default { $_ }
        }
    }

    $attributeRows = [System.Collections.Generic.List[object]]::new()

    function ConvertTo-Change {
        param([System.Collections.IDictionary] $Item, [string] $Source)

        $change = $Item['change']
        if ($change -isnot [System.Collections.IDictionary]) { $change = @{} }
        $actions = @($change['actions'])
        $action = Get-Action $actions
        $before = $change['before']
        $after = $change['after']
        $previous = [string]$Item['previous_address']
        $deposed = [string]$Item['deposed']
        $importing = $change['importing'] -is [System.Collections.IDictionary]
        if ($action -eq 'NoOp' -and $importing) { $action = 'Import' }
        elseif ($action -eq 'NoOp' -and $previous) { $action = 'Move' }

        $flattener = [AzureAdminConsole.TerraformPlanFlattener]
        $leaves = if ($action -notin 'NoOp', 'Import', 'Move', 'Forget') {
            $flattener::Flatten($before, $after, $change['after_unknown'], $change['before_sensitive'], $change['after_sensitive'], $(if ($Item['mode'] -eq 'output') { 'value' } else { '' }), $change['replace_paths'])
        }
        $address = [string]$Item['address']
        $mode = [string]$Item['mode']
        $type = [string]$Item['type']
        $provider = [string]$Item['provider_name']
        $resourceName = $flattener::Fact($after, $before, 'name')
        # A literal [pscustomobject]@{} per row: a plan can change a hundred
        # thousand attributes, and building each row any other way is slower.
        $rows = foreach ($leaf in $leaves) {
            [pscustomobject]@{
                PSTypeName        = 'AAC.TerraformAttributeChange'
                Source            = $Source
                Action            = $action
                Address           = $address
                Type              = $type
                ResourceName      = $resourceName
                Attribute         = $leaf.Attribute
                Change            = $leaf.Change
                Before            = $leaf.Before
                After             = $leaf.After
                ForcesReplacement = $leaf.ForcesReplacement
                Sensitive         = $leaf.Sensitive
            }
        }
        $rows = @($rows)
        $attributeRows.AddRange([object[]]$rows)

        [pscustomobject][ordered]@{
            PSTypeName        = 'AAC.TerraformChange'
            Source            = $Source
            Action            = $action
            Address           = $address
            Module            = [string]$Item['module_address']
            Mode              = $mode
            Type              = $type
            Name              = [string]$Item['name']
            Index             = $(if ($Item.Contains('index')) { [string]$Item['index'] })
            Provider          = $(if ($provider) { ($provider -split '/')[-1] })
            ResourceName      = $resourceName
            ResourceGroup     = $flattener::Fact($after, $before, 'resource_group_name')
            Location          = $flattener::Fact($after, $before, 'location')
            ResourceId        = $flattener::Fact($before, $after, 'id')
            Reason            = $(if ($Item['action_reason']) { $(if ($reasons.Contains([string]$Item['action_reason'])) { $reasons[[string]$Item['action_reason']] } else { [string]$Item['action_reason'] }) } elseif ($deposed) { 'a deposed object: the old copy left when a create_before_destroy replacement failed' })
            Deposed           = $deposed
            ReplaceOrder      = $(switch ($actions -join ',') { 'delete,create' { 'Destroy then create' } 'create,delete' { 'Create then destroy' } })
            ReplacePaths      = $flattener::ReplacePathLabels($change['replace_paths']) -join ', '
            PreviousAddress   = $previous
            Importing         = $importing
            ChangedAttributes = @(foreach ($row in $rows) { $row.Attribute }) -join ', '
            AttributeCount    = $rows.Count
            Attributes        = $rows
        }
    }

    $resourceItems = @($Plan['resource_changes'] | Where-Object { $_ -is [System.Collections.IDictionary] })
    $driftItems = @($Plan['resource_drift'] | Where-Object { $_ -is [System.Collections.IDictionary] })
    $total = $resourceItems.Count + $driftItems.Count
    $done = 0
    $changes = [System.Collections.Generic.List[object]]::new()
    foreach ($pair in @(@{ Items = $resourceItems; Source = 'Plan' }, @{ Items = $driftItems; Source = 'Drift' })) {
        foreach ($item in $pair.Items) {
            $changes.Add((ConvertTo-Change -Item $item -Source $pair.Source))
            $done++
            if ($OnProgress -and ($done % 25 -eq 0 -or $done -eq $total)) { & $OnProgress $done $total }
        }
    }
    $outputs = $Plan['output_changes']
    if ($outputs -is [System.Collections.IDictionary]) {
        $names = [string[]]@($outputs.Keys)
        [Array]::Sort($names, [StringComparer]::Ordinal)
        foreach ($name in $names) {
            $changes.Add((ConvertTo-Change -Item @{ address = "output.$name"; mode = 'output'; type = 'output'; name = $name; change = $outputs[$name] } -Source 'Plan'))
        }
    }

    $sorted = @($changes | Sort-Object -Property @{ Expression = { $actionOrder[$_.Action] ?? 9 } }, Address)
    # Counted in one pass each: a plan can hold a hundred thousand rows.
    $stats = [ordered]@{ Resources = 0; Create = 0; Update = 0; Replace = 0; Delete = 0; Read = 0; Forget = 0; NoOp = 0; Import = 0; Move = 0; Outputs = 0; Drift = 0; Attributes = 0; Sensitive = 0; Forced = 0 }
    foreach ($change in $sorted) {
        if ($change.Source -eq 'Drift') { $stats.Drift++; continue }
        if ($change.Mode -eq 'output') { if ($change.Action -ne 'NoOp') { $stats.Outputs++ }; continue }
        $stats.Resources++
        if ($stats.Contains($change.Action)) { $stats[$change.Action]++ }
        if ($change.Importing) { $stats.Import++ }
        if ($change.PreviousAddress) { $stats.Move++ }
    }
    foreach ($row in $attributeRows) {
        if ($row.Source -eq 'Plan') { $stats.Attributes++ }
        if ($row.Sensitive) { $stats.Sensitive++ }
        if ($row.ForcesReplacement) { $stats.Forced++ }
    }
    # Terraform's own summary line: a replacement adds one and destroys one.
    $stats['ToAdd'] = $stats.Create + $stats.Replace
    $stats['ToChange'] = $stats.Update
    $stats['ToDestroy'] = $stats.Delete + $stats.Replace

    # Check results (check blocks, preconditions, postconditions): those
    # that fail, error or can't be known until apply, with their messages.
    $checks = @(foreach ($check in @($Plan['checks'])) {
            if ($check -isnot [System.Collections.IDictionary] -or [string]$check['status'] -notin 'fail', 'error', 'unknown') { continue }
            $problems = @(foreach ($instance in @($check['instances'])) {
                    if ($instance -isnot [System.Collections.IDictionary]) { continue }
                    foreach ($problem in @($instance['problems'])) { if ($problem -is [System.Collections.IDictionary] -and $problem['message']) { [string]$problem['message'] } }
                })
            $address = if ($check['address'] -is [System.Collections.IDictionary]) { [string]$check['address']['to_display'] } else { '' }
            [pscustomobject]@{ Address = $address; Status = [string]$check['status']; Problems = @($problems | Select-Object -Unique) }
        })
    $stats['ChecksFailed'] = @($checks | Where-Object Status -In 'fail', 'error').Count
    $stats['ChecksUnknown'] = @($checks | Where-Object Status -EQ 'unknown').Count

    $info = [ordered]@{
        Path             = $Path
        TerraformVersion = [string]$Plan['terraform_version']
        FormatVersion    = [string]$Plan['format_version']
        # ConvertFrom-Json reads the ISO 8601 text as a UTC [datetime].
        Timestamp        = $(if ($Plan['timestamp'] -is [datetime]) { $Plan['timestamp'].ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'", [cultureinfo]::InvariantCulture) } elseif ($Plan['timestamp']) { [string]$Plan['timestamp'] })
        Applyable        = $(if ($Plan.Contains('applyable')) { [bool]$Plan['applyable'] })
        Complete         = $(if ($Plan.Contains('complete')) { [bool]$Plan['complete'] })
        Errored          = [bool]$Plan['errored']
        Checks           = $checks
    }

    @{
        Info       = $info
        Changes    = $sorted
        Stats      = $stats
    }
}