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 } } |