Public/Get-AACTerraformPlan.ps1
|
function Get-AACTerraformPlan { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Reads a Terraform plan in JSON (terraform show -json) and flattens it into what will be created, updated, replaced, deleted, read, imported and moved - down to each attribute's before and after value - with a Spectre.Console view, objects, and CSV and interactive HTML reports. .DESCRIPTION Reads the whole plan file - every resource change, the outputs, and what changed outside Terraform since the last run (resource_drift) - and flattens it so it reads at a glance. Offline: no Azure sign-in, no Terraform needed, nothing changed. Make the JSON from a saved plan: terraform plan -out tfplan terraform show -json tfplan > plan.json A binary plan (tfplan), Terraform state or any other JSON is refused, with what to run instead. UTF-16 files (Windows PowerShell's >) are read as well. One row per resource (AAC.TerraformChange): Action, Address, Module, Type, Name, Index, Provider, the Azure name, resource group, location and ID where the resource has them, Reason (why it is replaced or deleted), ReplaceOrder, ReplacePaths (the attributes that force the replacement), PreviousAddress (moved), Importing, Deposed (the old copy a failed create_before_destroy left behind), ChangedAttributes and Attributes. Actions: Create, Update, Replace, Delete, Read (data sources), Import, Move, Forget and NoOp. Outputs are rows too, with Mode 'output' and Address 'output.<name>'. -ExpandAttribute returns one row per changed attribute instead (AAC.TerraformAttributeChange): Address, Attribute, Change (Added, Removed, Modified, Known after apply, Reordered, Reformatted), Before, After, ForcesReplacement and Sensitive. Nested values are flattened to a path: tags["cost.centre"] a map key site_config[0].always_on a nested block security_rule[name=ssh].destination_port_range a block in a list, by its name policy_rule{json}.then.effect inside a JSON string Updates and replacements list only what changes. Blocks in a list are matched by content, then by name, then in order - a rule added to an NSG doesn't show every rule after it as changed. Checks (check blocks, preconditions, postconditions) that fail, or can't be known until apply, are listed with their messages. Sensitive values are never shown or returned: the plan JSON holds them in clear text, and they come back as '(sensitive)'. Values Terraform knows only after apply read '(known after apply)'. What you get depends on where the command runs: at the prompt Terraform's summary (to add, change, destroy), tiles, then the deletes, replacements, updates, creates and the rest - each with its attribute changes - the outputs and the drift, a page at a time piped onward the rows, with no view -PassThru the view and the rows -NoDisplay the rows only -CsvPath writes the rows returned (resources, or attributes with -ExpandAttribute). -HtmlPath writes an interactive report: tiles and charts that filter tables of the resources, every attribute change, the outputs and the drift - each searchable and downloadable as CSV. .PARAMETER Path The plan in JSON: terraform show -json tfplan > plan.json. .PARAMETER Action Only these actions: Create, Update, Replace, Delete, Read, Import, Move, Forget, NoOp. Every action but NoOp by default. .PARAMETER Address Only resources whose address matches one of these (wildcards): 'module.network.*', '*azurerm_key_vault*'. .PARAMETER ResourceType Only these resource types (wildcards): 'azurerm_storage_account', 'azurerm_network_*'. .PARAMETER ExpandAttribute Return (and write to -CsvPath) one row per changed attribute, not per resource. .PARAMETER IncludeDrift Also return the changes made outside Terraform (Source 'Drift'). The view always shows them. .PARAMETER CsvPath Write the rows to this CSV file. .PARAMETER HtmlPath Write an interactive HTML report to this file. .PARAMETER Title The HTML report's title. .PARAMETER PassThru Show the view and also return the rows. .PARAMETER NoDisplay Return the rows without showing the view. .PARAMETER NoPaging Show the whole view at once instead of a page at a time. .EXAMPLE terraform plan -out tfplan terraform show -json tfplan > plan.json Get-AACTerraformPlan -Path .\plan.json What the plan changes, at the prompt. .EXAMPLE Get-AACTerraformPlan -Path .\plan.json -Action Delete, Replace -NoDisplay | Select-Object Address, ResourceName, Reason, ReplacePaths Everything the plan destroys, and why. .EXAMPLE Get-AACTerraformPlan -Path .\plan.json -ExpandAttribute -NoDisplay | Where-Object ForcesReplacement The attribute changes that force a replacement. .EXAMPLE Get-AACTerraformPlan -Path .\plan.json -HtmlPath .\out\Plan.html -CsvPath .\out\Plan.csv -ExpandAttribute An HTML report, and every attribute change as CSV - for a pull request or a pipeline artifact. .OUTPUTS AAC.TerraformChange, or AAC.TerraformAttributeChange with -ExpandAttribute (piped onward, or with -PassThru or -NoDisplay) #> [CmdletBinding()] [OutputType('AAC.TerraformChange', 'AAC.TerraformAttributeChange')] param( [Parameter(Mandatory, Position = 0)] [Alias('FullName', 'PlanFile')] [ValidateNotNullOrEmpty()] [string] $Path, [ValidateSet('Create', 'Update', 'Replace', 'Delete', 'Read', 'Import', 'Move', 'Forget', 'NoOp')] [string[]] $Action, [ValidateNotNullOrEmpty()] [string[]] $Address, [ValidateNotNullOrEmpty()] [string[]] $ResourceType, [switch] $ExpandAttribute, [switch] $IncludeDrift, [string] $CsvPath, [string] $HtmlPath, [string] $Title = 'Terraform plan', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) # A failure anywhere below ends as a Spectre.Console error panel and this # command's own terminating error, not a line inside the module. A stopped # pipeline (Select-Object -First, Ctrl+C) is no failure: just return - a # rethrow would stop the caller's whole script, not only this command. 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 $showView = $interactive -and -not ($CsvPath -or $HtmlPath) $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $planFullPath = & $resolve $Path $csvFullPath = & $resolve $CsvPath $htmlFullPath = & $resolve $HtmlPath if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Terraform plan' -Color 'mediumpurple2' } # The filters, read here: the script block below runs inside # Invoke-AACProgress, and is safer not reaching back for parameters. $filter = @{ Action = @($Action | Where-Object { $_ }) Address = @($Address | Where-Object { $_ }) ResourceType = @($ResourceType | Where-Object { $_ }) ExpandAttribute = [bool]$ExpandAttribute IncludeDrift = [bool]$IncludeDrift Title = $Title } $state = Invoke-AACProgress -ScriptBlock { $fileName = Split-Path -Path $planFullPath -Leaf $showJson = "terraform show -json $(if ($fileName -match '\.json$') { 'tfplan' } else { $fileName }) > plan.json" $refuse = { param([string] $Message, [string] $Hint) $problem = [System.InvalidOperationException]::new($Message) $problem.Data['AACHint'] = $Hint throw $problem } if (-not (Test-Path -LiteralPath $planFullPath -PathType Leaf)) { & $refuse "No file at '$planFullPath'." "Save the plan and convert it to JSON: terraform plan -out tfplan, then terraform show -json tfplan > plan.json." } Update-AACProgress -Id 'read' -Description "Reading $fileName" -Indeterminate # A saved plan (terraform plan -out) is a zip file: 'PK' first. $head = [byte[]]::new(2) $stream = [System.IO.File]::OpenRead($planFullPath) try { $null = $stream.Read($head, 0, 2) } finally { $stream.Dispose() } if ($head[0] -eq 0x50 -and $head[1] -eq 0x4B) { & $refuse "'$fileName' is a binary Terraform plan, not JSON." "Convert it first: $showJson" } # ReadAllText follows the byte order mark: UTF-8, or UTF-16 from # Windows PowerShell's > redirection. $text = [System.IO.File]::ReadAllText($planFullPath) try { $plan = ConvertFrom-Json -InputObject $text -AsHashtable -ErrorAction Stop } catch { & $refuse "'$fileName' isn't valid JSON: $($_.Exception.Message)" "Make the JSON with Terraform itself: $showJson" } if ($plan -isnot [System.Collections.IDictionary] -or -not $plan.Contains('format_version')) { & $refuse "'$fileName' isn't Terraform's JSON output: it has no format_version." "Make it with: $showJson" } if (-not ($plan.Contains('resource_changes') -or $plan.Contains('output_changes') -or $plan.Contains('planned_values'))) { & $refuse "'$fileName' is Terraform state, not a plan: it has no planned changes." "Run terraform show -json on a saved plan file, not on its own: terraform plan -out tfplan, then terraform show -json tfplan > plan.json." } $text = $null $total = @($plan['resource_changes']).Count + @($plan['resource_drift']).Count Update-AACProgress -Id 'read' -Total ([Math]::Max($total, 1)) -Description "Flattening $total resource change(s) in $fileName" $tick = @{ Done = 0 } $result = ConvertTo-AACTerraformPlan -Plan $plan -Path $planFullPath -OnProgress { param($Done, $Total) Update-AACProgress -Id 'read' -Increment ($Done - $tick.Done) -Description "Flattening the resource changes ($Done of $Total)" $tick.Done = $Done } $plan = $null $stats = $result.Stats Update-AACProgress -Id 'read' -Complete -Description ('{0}: {1:N0} to add, {2:N0} to change, {3:N0} to destroy - {4:N0} attribute change(s)' -f $fileName, $stats.ToAdd, $stats.ToChange, $stats.ToDestroy, $stats.Attributes) # The filters: actions (all but no-op by default), addresses, types. $actions = if ($filter.Action) { $filter.Action } else { 'Create', 'Update', 'Replace', 'Delete', 'Read', 'Import', 'Move', 'Forget' } $like = { param([string] $Value, [string[]] $Pattern) foreach ($item in $Pattern) { if ($Value -like $item) { return $true } }; $false } $selected = @($result.Changes | Where-Object { $_.Action -in $actions -and (-not $filter.Address -or (& $like $_.Address $filter.Address)) -and (-not $filter.ResourceType -or ($_.Mode -ne 'output' -and (& $like $_.Type $filter.ResourceType))) }) $result['Selected'] = $selected $result['Filtered'] = [bool]($filter.Action -or $filter.Address -or $filter.ResourceType) $notices = [System.Collections.Generic.List[string]]::new() $hasChanges = [bool]($stats.ToAdd + $stats.ToChange + $stats.ToDestroy + $stats.Import + $stats.Move + $stats.Read + $stats.Forget + $stats.Outputs) if ($result.Info.Errored) { $notices.Add('Terraform hit an error while planning: this plan is incomplete and can''t be applied. Fix the errors terraform plan reported and plan again.') } elseif ($false -eq $result.Info.Applyable -and $hasChanges) { $notices.Add('Terraform marked this plan as not applyable.') } elseif ($false -eq $result.Info.Complete) { $notices.Add('This plan is incomplete: applying it leaves changes for another plan (deferred or partial changes).') } foreach ($check in @($result.Info.Checks)) { $what = if ($check.Status -eq 'unknown') { 'can''t be checked until apply' } else { "fails ($($check.Status))" } $notices.Add("Check $($check.Address) $what$(if ($check.Problems) { ': ' + ($check.Problems -join ' ') })") } if ($stats.Sensitive) { $notices.Add("$($stats.Sensitive) sensitive value(s) are hidden as '(sensitive)'.") } if (-not $hasChanges) { $notices.Add('No changes: the infrastructure matches the configuration.') } elseif ($result.Filtered -and -not $selected.Count) { $notices.Add('No changes match -Action, -Address or -ResourceType.') } $result['Notice'] = $notices.ToArray() $rows = [System.Collections.Generic.List[object]]::new() foreach ($change in $selected) { if (-not $filter.IncludeDrift -and $change.Source -ne 'Plan') { continue } if ($filter.ExpandAttribute) { $rows.AddRange([object[]]$change.Attributes) } else { $rows.Add($change) } } $rows = $rows.ToArray() $result['Rows'] = $rows $detail = [ordered]@{ 'Plan file' = $planFullPath } if ($result.Info.TerraformVersion) { $detail['Terraform'] = "v$($result.Info.TerraformVersion) (format $($result.Info.FormatVersion))" } if ($result.Info.Timestamp) { $detail['Planned'] = [string]$result.Info.Timestamp } if ($filter.Action) { $detail['Actions'] = $filter.Action -join ', ' } if ($filter.Address) { $detail['Addresses'] = $filter.Address -join ', ' } if ($filter.ResourceType) { $detail['Resource types'] = $filter.ResourceType -join ', ' } $result['Detail'] = $detail $csvRows = if ($filter.ExpandAttribute) { $rows } else { @($rows | Select-Object -Property * -ExcludeProperty Attributes) } $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject @($csvRows) -Noun $(if ($filter.ExpandAttribute) { 'attribute change' } else { 'resource change' }) -HtmlPath $htmlFullPath -WriteHtml { Write-AACTerraformPlanHtml -Plan $result -Path $htmlFullPath -Title $filter.Title -Detail $detail } $result } if ($showView) { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { Show-AACTerraformPlanView -Plan $state } } elseif ($interactive) { foreach ($notice in @($state.Notice)) { Write-AACMarkup "[grey58]$([Spectre.Console.Markup]::Escape($notice))[/]" } } if ($returnObjects) { $state.Rows } } |