lib/Parse-TerraformPlan.ps1

#requires -version 7.0
<#
.SYNOPSIS
    Normalizes `terraform show -json` plan output into a report model consumed
    by the HTML report template.

.DESCRIPTION
    Dot-source this file to import ConvertTo-PlanReportModel. The function takes
    the PSCustomObject produced by `ConvertFrom-Json` on `terraform show -json`
    output and returns a plain object shaped for direct ConvertTo-Json
    serialization into the report template.
#>


function ConvertTo-PlanReportModel {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory)]
        [psobject]
        $PlanJson
    )

    # Normalizes a Terraform `change.actions` array into a single action label.
    function Get-NormalizedAction {
        param([string[]] $Actions)

        if (-not $Actions -or $Actions.Count -eq 0) {
            return 'no-op'
        }

        # Wrap in @() explicitly: Sort-Object collapses a single-result
        # pipeline to a scalar, and indexing a scalar *string* with [0]
        # returns its first character rather than the element itself.
        $set = @($Actions | Sort-Object -Unique)

        if ($set.Count -eq 1) {
            switch ($set[0]) {
                'no-op' { return 'no-op' }
                'create' { return 'create' }
                'update' { return 'update' }
                'delete' { return 'destroy' }
                'read' { return 'read' }
                default { return $set[0] }
            }
        }

        if (($set -join ',') -eq 'create,delete') {
            return 'replace'
        }

        # Fall back to a joined label for any unexpected combination rather
        # than silently dropping information.
        return ($Actions -join '+')
    }

    # Recursively walks a sensitivity marker tree (as produced by Terraform
    # for compound before_sensitive/after_sensitive values: nested
    # objects/arrays mirroring the value's shape, with booleans at the
    # leaves) and returns $true only if a genuine `true` leaf exists
    # anywhere inside it. A nested marker with no `true` leaves means
    # nothing in that value is actually sensitive.
    function Test-MarkerTreeSensitive {
        param($Marker)

        if ($null -eq $Marker) {
            return $false
        }

        if ($Marker -is [bool]) {
            return $Marker
        }

        if ($Marker -is [System.Collections.IDictionary]) {
            foreach ($v in $Marker.Values) {
                if (Test-MarkerTreeSensitive -Marker $v) { return $true }
            }
            return $false
        }

        if ($Marker -is [System.Management.Automation.PSCustomObject]) {
            foreach ($p in $Marker.PSObject.Properties) {
                if (Test-MarkerTreeSensitive -Marker $p.Value) { return $true }
            }
            return $false
        }

        if ($Marker -is [System.Collections.IEnumerable] -and -not ($Marker -is [string])) {
            foreach ($item in $Marker) {
                if (Test-MarkerTreeSensitive -Marker $item) { return $true }
            }
            return $false
        }

        # Any other scalar (string/number/etc.) isn't a recognized
        # sensitivity marker shape — treat as not sensitive rather than
        # guessing.
        return $false
    }

    # Returns $true if a sensitivity marker for a given key indicates the
    # value should be treated as sensitive. Terraform represents this either
    # as a flat bool-per-key map, or as a nested structure (e.g. for
    # objects/lists) mirroring the value's shape, with booleans at the
    # leaves — a nested marker only means the attribute is sensitive if a
    # `true` leaf actually exists somewhere inside it (see
    # Test-MarkerTreeSensitive). We only diff top-level attributes, so a
    # partially-sensitive nested value still masks the whole attribute
    # (the value is rendered as one JSON blob), but a nested marker with no
    # `true` anywhere no longer falsely masks the attribute.
    function Test-AttributeSensitive {
        param(
            [psobject] $SensitivityMap,
            [string] $Key
        )

        if ($null -eq $SensitivityMap) {
            return $false
        }

        if ($SensitivityMap -is [System.Collections.IDictionary]) {
            if (-not $SensitivityMap.Contains($Key)) {
                return $false
            }
            $marker = $SensitivityMap[$Key]
        }
        else {
            $prop = $SensitivityMap.PSObject.Properties[$Key]
            if (-not $prop) {
                return $false
            }
            $marker = $prop.Value
        }

        if ($null -eq $marker) {
            return $false
        }

        if ($marker -is [bool]) {
            return $marker
        }

        return (Test-MarkerTreeSensitive -Marker $marker)
    }

    # Renders a value for display: primitives pass through, everything else
    # (objects/arrays/null) is pretty-printed as JSON so nested structures
    # remain inspectable without being deep-diffed (v1 simplification).
    function ConvertTo-DisplayValue {
        param($Value)

        if ($null -eq $Value) {
            return $null
        }

        if ($Value -is [string] -or $Value -is [bool] -or
            $Value -is [int] -or $Value -is [long] -or
            $Value -is [double] -or $Value -is [decimal]) {
            return $Value
        }

        try {
            return ($Value | ConvertTo-Json -Depth 10 -Compress:$false)
        }
        catch {
            return [string]$Value
        }
    }

    # Builds the union of top-level property names across before/after
    # objects, preserving a stable order (before's keys first, then any
    # after-only keys).
    function Get-UnionKeys {
        param($Before, $After)

        $keys = [System.Collections.Specialized.OrderedDictionary]::new()

        if ($Before -is [psobject] -and -not ($Before -is [System.Collections.IDictionary])) {
            foreach ($p in $Before.PSObject.Properties) {
                $keys[$p.Name] = $true
            }
        }
        if ($After -is [psobject] -and -not ($After -is [System.Collections.IDictionary])) {
            foreach ($p in $After.PSObject.Properties) {
                $keys[$p.Name] = $true
            }
        }

        return @($keys.Keys)
    }

    function Get-PropertyValue {
        param($Obj, [string] $Key)

        if ($null -eq $Obj) {
            return $null
        }

        $prop = $Obj.PSObject.Properties[$Key]
        if (-not $prop) {
            return $null
        }

        return $prop.Value
    }

    # Strips a trailing count/for_each index suffix (e.g. "[0]", "[\"key\"]")
    # from a resource_changes-style address, yielding the address of the
    # resource *block* as it appears in `configuration`.
    function Get-BaseAddress {
        param([string] $Address)

        if ([string]::IsNullOrEmpty($Address)) {
            return $Address
        }

        return [regex]::Replace($Address, '\[[^\]]*\]$', '')
    }

    # Recursively walks a `configuration` module node (root_module or a
    # module_call's `.module`), returning one entry per configured resource
    # block: its module-prefix-qualified base address and its raw
    # `expressions` value (used later to find references). Recurses into
    # `module_calls` building up the `module.<name>.` prefix chain.
    function Get-ConfiguredResources {
        param($ModuleNode, [string] $Prefix)

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

        if ($null -eq $ModuleNode) {
            return $results
        }

        $resources = $null
        try { $resources = Get-PropertyValue -Obj $ModuleNode -Key 'resources' } catch { $resources = $null }

        if ($resources) {
            foreach ($r in @($resources)) {
                if ($null -eq $r) { continue }

                $addr = $null
                try { $addr = $r.address } catch { $addr = $null }
                if ([string]::IsNullOrEmpty($addr)) { continue }

                $results.Add([pscustomobject]@{
                    address     = "$Prefix$addr"
                    prefix      = $Prefix
                    expressions = (Get-PropertyValue -Obj $r -Key 'expressions')
                })
            }
        }

        $moduleCalls = $null
        try { $moduleCalls = Get-PropertyValue -Obj $ModuleNode -Key 'module_calls' } catch { $moduleCalls = $null }

        if ($moduleCalls) {
            foreach ($callProp in $moduleCalls.PSObject.Properties) {
                $childModule = Get-PropertyValue -Obj $callProp.Value -Key 'module'
                $childPrefix = "${Prefix}module.$($callProp.Name)."
                foreach ($child in (Get-ConfiguredResources -ModuleNode $childModule -Prefix $childPrefix)) {
                    $results.Add($child)
                }
            }
        }

        return $results
    }

    # Recursively collects every string found in any property/array-element
    # literally named "references" (at any nesting depth) under an
    # `expressions` value, deduplicated preserving first-seen order.
    function Get-AllReferences {
        param($Node)

        $refs = [System.Collections.Generic.List[string]]::new()

        function Add-References {
            param($Node)

            if ($null -eq $Node) { return }
            if ($Node -is [string]) { return }

            if ($Node -is [System.Collections.IEnumerable] -and -not ($Node -is [System.Collections.IDictionary])) {
                foreach ($item in $Node) { Add-References -Node $item }
                return
            }

            if ($Node -is [System.Management.Automation.PSCustomObject]) {
                foreach ($p in $Node.PSObject.Properties) {
                    if ($p.Name -eq 'references' -and
                        $p.Value -is [System.Collections.IEnumerable] -and
                        -not ($p.Value -is [string])) {
                        foreach ($item in $p.Value) {
                            if ($item -is [string]) { $refs.Add($item) }
                        }
                    }
                    else {
                        Add-References -Node $p.Value
                    }
                }
            }
        }

        Add-References -Node $Node
        return @($refs | Select-Object -Unique)
    }

    # Resolves a raw reference string (e.g. "random_pet.demo.id", "var.x",
    # "data.aws_ami.foo.id") to a candidate module-prefix-qualified base
    # address, using only the first two dot-separated segments (three if it
    # starts with "data."). Returns $null if the reference doesn't even look
    # like a direct resource reference (caller still needs to check the
    # candidate against known configured resources).
    function Resolve-ReferenceCandidate {
        param([string] $RawRef, [string] $Prefix)

        if ([string]::IsNullOrWhiteSpace($RawRef)) {
            return $null
        }

        $parts = $RawRef -split '\.'

        if ($parts[0] -eq 'data') {
            if ($parts.Count -lt 3) { return $null }
            return "$Prefix$($parts[0..2] -join '.')"
        }

        if ($parts.Count -lt 2) {
            return $null
        }

        return "$Prefix$($parts[0..1] -join '.')"
    }

    $summary = [ordered]@{
        create  = 0
        update  = 0
        destroy = 0
        replace = 0
        noop    = 0
    }

    $moduleGroups = [System.Collections.Specialized.OrderedDictionary]::new()

    $resourceChanges = @()
    try {
        $resourceChanges = @($PlanJson.resource_changes)
    }
    catch {
        Write-Error "Failed to read 'resource_changes' from plan JSON: $($_.Exception.Message)"
        throw
    }

    # ---- Dependency resolution (configuration.root_module) ----
    #
    # Builds two lookups keyed by module-prefix-qualified base address:
    # $dependsOnMap[base] -> distinct list of base addresses it references
    # $referencedByMap[base] -> distinct list of base addresses that reference it
    # Only same-module resource-to-resource references are resolved; anything
    # else (variables, locals, module outputs, path/count/each expressions,
    # or references to resources outside this plan) is silently dropped.
    $configuredResources = @()
    try {
        $rootModule = Get-PropertyValue -Obj $PlanJson -Key 'configuration'
        if ($rootModule) { $rootModule = Get-PropertyValue -Obj $rootModule -Key 'root_module' }
        if ($rootModule) {
            $configuredResources = @(Get-ConfiguredResources -ModuleNode $rootModule -Prefix '')
        }
    }
    catch {
        $configuredResources = @()
    }

    $knownBaseAddresses = [System.Collections.Generic.HashSet[string]]::new()
    foreach ($cr in $configuredResources) { [void]$knownBaseAddresses.Add($cr.address) }

    $dependsOnMap = [System.Collections.Specialized.OrderedDictionary]::new()
    $referencedByMap = [System.Collections.Specialized.OrderedDictionary]::new()

    foreach ($cr in $configuredResources) {
        $rawRefs = Get-AllReferences -Node $cr.expressions

        foreach ($raw in $rawRefs) {
            $candidate = Resolve-ReferenceCandidate -RawRef $raw -Prefix $cr.prefix
            if ([string]::IsNullOrEmpty($candidate)) { continue }
            if ($candidate -eq $cr.address) { continue }
            if (-not $knownBaseAddresses.Contains($candidate)) { continue }

            if (-not $dependsOnMap.Contains($cr.address)) {
                $dependsOnMap[$cr.address] = [System.Collections.Generic.List[string]]::new()
            }
            if (-not $dependsOnMap[$cr.address].Contains($candidate)) {
                $dependsOnMap[$cr.address].Add($candidate)
            }

            if (-not $referencedByMap.Contains($candidate)) {
                $referencedByMap[$candidate] = [System.Collections.Generic.List[string]]::new()
            }
            if (-not $referencedByMap[$candidate].Contains($cr.address)) {
                $referencedByMap[$candidate].Add($cr.address)
            }
        }
    }

    # Base address -> actual resource_changes[].address instance(s) present
    # in this plan (a base address can map to several when count/for_each is
    # used). Used to turn dependsOn/referencedBy base addresses into the
    # concrete instance addresses actually rendered in the report.
    $instanceAddressesByBase = [System.Collections.Specialized.OrderedDictionary]::new()
    foreach ($rc in $resourceChanges) {
        if ($null -eq $rc) { continue }

        $rcAddress = $null
        try { $rcAddress = $rc.address } catch { $rcAddress = $null }
        if ([string]::IsNullOrEmpty($rcAddress)) { continue }

        $rcBase = Get-BaseAddress -Address $rcAddress
        if (-not $instanceAddressesByBase.Contains($rcBase)) {
            $instanceAddressesByBase[$rcBase] = [System.Collections.Generic.List[string]]::new()
        }
        if (-not $instanceAddressesByBase[$rcBase].Contains($rcAddress)) {
            $instanceAddressesByBase[$rcBase].Add($rcAddress)
        }
    }

    # Resolves a list of base addresses (from $dependsOnMap/$referencedByMap)
    # to the concrete instance addresses of that base address currently
    # present in this plan's resource_changes. Targets not present in this
    # plan are silently omitted.
    function Get-InstanceAddresses {
        param([System.Collections.IEnumerable] $BaseAddresses, $Lookup)

        $result = [System.Collections.Generic.List[string]]::new()
        if ($null -eq $BaseAddresses) { return $result }

        foreach ($base in $BaseAddresses) {
            if ($Lookup.Contains($base)) {
                foreach ($inst in $Lookup[$base]) {
                    if (-not $result.Contains($inst)) { $result.Add($inst) }
                }
            }
        }

        return $result
    }

    foreach ($rc in $resourceChanges) {
        if ($null -eq $rc) { continue }

        $address = $null
        try { $address = $rc.address } catch { $address = $null }

        $moduleAddress = $null
        try {
            if ($rc.PSObject.Properties['module_address']) {
                $moduleAddress = $rc.module_address
            }
        }
        catch {
            $moduleAddress = $null
        }
        if ([string]::IsNullOrEmpty($moduleAddress)) {
            $moduleAddress = 'root'
        }

        $change = $null
        try { $change = $rc.change } catch { $change = $null }

        $actions = @()
        if ($change -and $change.PSObject.Properties['actions']) {
            $actions = @($change.actions)
        }
        $action = Get-NormalizedAction -Actions $actions

        switch ($action) {
            'create' { $summary.create++ }
            'update' { $summary.update++ }
            'destroy' { $summary.destroy++ }
            'replace' { $summary.replace++ }
            'no-op' { $summary.noop++ }
            default { }
        }

        $before = $null
        $after = $null
        $beforeSensitive = $null
        $afterSensitive = $null

        if ($change) {
            try { if ($change.PSObject.Properties['before']) { $before = $change.before } } catch { $before = $null }
            try { if ($change.PSObject.Properties['after']) { $after = $change.after } } catch { $after = $null }
            try { if ($change.PSObject.Properties['before_sensitive']) { $beforeSensitive = $change.before_sensitive } } catch { $beforeSensitive = $null }
            try { if ($change.PSObject.Properties['after_sensitive']) { $afterSensitive = $change.after_sensitive } } catch { $afterSensitive = $null }
        }

        $keys = Get-UnionKeys -Before $before -After $after

        $attributes = @()
        foreach ($key in $keys) {
            $beforeVal = Get-PropertyValue -Obj $before -Key $key
            $afterVal = Get-PropertyValue -Obj $after -Key $key

            $hasBefore = ($null -ne $before) -and ($null -ne $before.PSObject.Properties[$key])
            $hasAfter = ($null -ne $after) -and ($null -ne $after.PSObject.Properties[$key])

            # Determine "changed": for create (no before object at all) every
            # present key is changed; for destroy (no after object at all)
            # every present key is changed; otherwise compare serialized
            # representations of before/after.
            $changed = $false
            if ($null -eq $before -and $null -ne $after) {
                $changed = $true
            }
            elseif ($null -eq $after -and $null -ne $before) {
                $changed = $true
            }
            else {
                $beforeJson = if ($hasBefore) { ($beforeVal | ConvertTo-Json -Depth 10 -Compress) } else { $null }
                $afterJson = if ($hasAfter) { ($afterVal | ConvertTo-Json -Depth 10 -Compress) } else { $null }
                $changed = ($beforeJson -ne $afterJson)
            }

            $sensitive = (Test-AttributeSensitive -SensitivityMap $beforeSensitive -Key $key) -or
                         (Test-AttributeSensitive -SensitivityMap $afterSensitive -Key $key)

            $attributes += [pscustomobject]@{
                name      = $key
                before    = ConvertTo-DisplayValue -Value $beforeVal
                after     = ConvertTo-DisplayValue -Value $afterVal
                changed   = [bool]$changed
                sensitive = [bool]$sensitive
            }
        }

        $baseAddress = Get-BaseAddress -Address $address

        $dependsOnBases = if ($dependsOnMap.Contains($baseAddress)) { $dependsOnMap[$baseAddress] } else { $null }
        $referencedByBases = if ($referencedByMap.Contains($baseAddress)) { $referencedByMap[$baseAddress] } else { $null }

        $dependsOn = @(Get-InstanceAddresses -BaseAddresses $dependsOnBases -Lookup $instanceAddressesByBase)
        $referencedBy = @(Get-InstanceAddresses -BaseAddresses $referencedByBases -Lookup $instanceAddressesByBase)

        $resourceModel = [pscustomobject]@{
            address      = $address
            type         = $rc.type
            name         = $rc.name
            module       = $moduleAddress
            action       = $action
            attributes   = $attributes
            dependsOn    = $dependsOn
            referencedBy = $referencedBy
        }

        if (-not $moduleGroups.Contains($moduleAddress)) {
            $moduleGroups[$moduleAddress] = [System.Collections.Generic.List[object]]::new()
        }
        $moduleGroups[$moduleAddress].Add($resourceModel)
    }

    $orderedModuleNames = @($moduleGroups.Keys | Where-Object { $_ -ne 'root' } | Sort-Object)
    if ($moduleGroups.Contains('root')) {
        $orderedModuleNames = @('root') + $orderedModuleNames
    }

    $modules = @()
    foreach ($name in $orderedModuleNames) {
        $modules += [pscustomobject]@{
            name      = $name
            resources = @($moduleGroups[$name])
        }
    }

    return [pscustomobject]@{
        summary = [pscustomobject]$summary
        modules = $modules
    }
}