Engines/Terraform/Invoke-AvmTerraformTransform.ps1
|
function Resolve-AvmMapotfConfigDir { <# .SYNOPSIS Resolve the directory holding the vendored mapotf pre-commit configs. .DESCRIPTION Returns the absolute path to the '*.mptf.hcl' bundle passed to 'mapotf transform --mptf-dir'. Resolution order: 1. $env:AVM_MPTF_CONFIG_DIR - explicit override (test injection and power users). 2. <ModuleRoot>/Resources/mapotf/pre-commit - forward-compatible location for when the configs ship inside the module itself. 3. <RepoRoot>/config/mapotf/pre-commit - the configs as currently vendored at the top of this repository (separate from the PowerShell module), per the 2026-06-19 vendoring decision. Each candidate must be a directory containing at least one '*.mptf.hcl' file. Throws AvmConfigurationException when none resolve, so the transform engine surfaces as 'skipped' (a deliberate placeholder) rather than running mapotf against an empty config set. .OUTPUTS [string] absolute path to the resolved config directory. #> [CmdletBinding()] [OutputType([string])] param() Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' $candidates = New-Object System.Collections.Generic.List[string] if ($env:AVM_MPTF_CONFIG_DIR) { $candidates.Add($env:AVM_MPTF_CONFIG_DIR) } $moduleRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) $candidates.Add((Join-Path $moduleRoot (Join-Path 'Resources' (Join-Path 'mapotf' 'pre-commit')))) $repoRoot = Split-Path -Parent (Split-Path -Parent $moduleRoot) $candidates.Add((Join-Path $repoRoot (Join-Path 'config' (Join-Path 'mapotf' 'pre-commit')))) foreach ($candidate in $candidates) { if (-not $candidate) { continue } if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { continue } $configs = @(Get-ChildItem -LiteralPath $candidate -Filter '*.mptf.hcl' -File -ErrorAction SilentlyContinue) if ($configs.Count -gt 0) { return (Resolve-Path -LiteralPath $candidate).ProviderPath } } throw [AvmConfigurationException]::new( ("Cannot resolve the mapotf pre-commit config bundle (looked in: {0}). " -f ($candidates -join '; ')) + 'Set the AVM_MPTF_CONFIG_DIR environment variable or restore config/mapotf/pre-commit/*.mptf.hcl.') } function Get-AvmTerraformFile { <# .SYNOPSIS Enumerate the '*.tf' files mapotf would touch under a module root. .DESCRIPTION Returns FileInfo records for every '*.tf' file beneath $Root, excluding any path segment that begins with '.' (e.g. '.terraform', '.git') or equals 'node_modules'. Used by Invoke-AvmTerraformTransform to snapshot file hashes before/after the transform so the engine can report which files mapotf changed. Always returns an array (empty when nothing matches) so callers can rely on '.Count'. .PARAMETER Root The module root to walk. .OUTPUTS [object[]] of System.IO.FileInfo. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)] [string] $Root ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' return @( Get-ChildItem -LiteralPath $Root -Recurse -File -Filter '*.tf' -ErrorAction SilentlyContinue | Where-Object { $rel = [System.IO.Path]::GetRelativePath($Root, $_.FullName) $parts = $rel -split '[\\/]' -not ($parts | Where-Object { $_.StartsWith('.') -or $_ -eq 'node_modules' }) } ) } function Invoke-AvmTerraformTransform { <# .SYNOPSIS Apply the AVM mapotf HCL transforms to a Terraform module. .DESCRIPTION Engine implementation called by Invoke-AvmTransform when the module context is Ecosystem='terraform'. Resolves the 'mapotf' binary via Resolve-AvmTool and the vendored config bundle via Resolve-AvmMapotfConfigDir, then runs, against $Context.Root: mapotf transform --mptf-dir <configs> --tf-dir <root> mapotf clean-backup --tf-dir <root> The first call mutates '*.tf' in place (telemetry wiring, azapi headers, provider pins, block/attribute ordering, variables/outputs partitioning) and leaves '*.tf.mptfbackup' files; the second removes those backups. This mirrors the upstream avm-terraform-governance pre-commit flow. Several of the vendored configs (e.g. order_resource_attrs) read provider schemas, so mapotf shells out to 'terraform init' + 'terraform providers schema'. mapotf locates 'terraform' by name on PATH, but GitHub-hosted runners no longer ship terraform on PATH (it was removed from the images). The engine therefore resolves the pinned terraform via Resolve-AvmTool and prepends its directory to PATH for the mapotf subprocess; environment variables propagate to mapotf's own terraform grandchild, so the schema reads succeed against the managed binary. A terraform that cannot be resolved (AvmToolException) propagates so the chain reports 'skipped', matching missing-mapotf. File-hash snapshots taken before and after the transform populate the 'Changed' field (relative paths of every '*.tf' mapotf added, removed or modified). Drift mode (-CheckDrift, used by pr-check): the transform still runs, but any 'Changed' file becomes a Status='fail' Issue. The contract is "a module that already ran pre-commit has nothing for mapotf to change"; a non-empty change set in CI therefore means the author did not run pre-commit, and pr-check flags it. mapotf exit codes: 0 = success; anything else is surfaced as AvmProcessException. A missing mapotf binary (AvmToolException) or a missing config bundle (AvmConfigurationException) propagates so the composition chain reports the step as 'skipped' on an unconfigured workstation. .PARAMETER Context Module context produced by Get-AvmModuleContext. Must have Ecosystem='terraform'. .PARAMETER AllowPathFallback Pass through to Resolve-AvmTool. .PARAMETER CheckDrift When set, treat any file mapotf changed as a failure (Status='fail' with one Issue per changed file) instead of a silent fix. Used by the pr-check chain. .OUTPUTS pscustomobject with Engine, Tool, ToolPath, ToolSource, Status, FilesProcessed, Changed, Issues. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] $Context, [switch] $AllowPathFallback, [switch] $CheckDrift ) Set-StrictMode -Version 3.0 $ErrorActionPreference = 'Stop' if ($Context.Ecosystem -ne 'terraform') { throw [System.ArgumentException]::new( "Invoke-AvmTerraformTransform requires a terraform context (got Ecosystem='$($Context.Ecosystem)').") } $tool = Resolve-AvmTool -Name 'mapotf' -AllowPathFallback:$AllowPathFallback $configDir = Resolve-AvmMapotfConfigDir $beforeFiles = Get-AvmTerraformFile -Root $Context.Root if (-not $PSCmdlet.ShouldProcess($Context.Root, ("mapotf transform --mptf-dir '{0}'" -f $configDir))) { return [pscustomobject][ordered]@{ Engine = 'terraform' Tool = ('{0}/{1}' -f $tool.Name, $tool.Version) ToolPath = $tool.Path ToolSource = $tool.Source Status = 'skipped' FilesProcessed = $beforeFiles.Count Changed = @() Issues = @() } } $before = @{} foreach ($f in $beforeFiles) { $before[$f.FullName] = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash } # mapotf reads provider schemas (order_resource_attrs et al.) by shelling # out to terraform, which it finds by name on PATH. GitHub-hosted runners # no longer ship terraform on PATH, so resolve the pinned terraform the # same way as mapotf (managed cache, not a stray PATH binary) and prepend # its directory to PATH for the mapotf subprocess. The override propagates # to mapotf's terraform grandchild. A missing terraform throws # AvmToolException, which the chain surfaces as 'skipped' just like a # missing mapotf binary. $terraform = Resolve-AvmTool -Name 'terraform' -AllowPathFallback:$AllowPathFallback $mapotfEnv = $null $terraformDir = Split-Path -Parent $terraform.Path if ($terraformDir) { $mapotfEnv = @{ PATH = ($terraformDir + [System.IO.Path]::PathSeparator + $env:PATH) } } $transform = Invoke-AvmProcess ` -FilePath $tool.Path ` -ArgumentList @('transform', '--mptf-dir', $configDir, '--tf-dir', $Context.Root) ` -WorkingDirectory $Context.Root ` -EnvVars $mapotfEnv ` -IgnoreExitCode if ($transform.ExitCode -ne 0) { $stderr = if ($transform.StdErr) { $transform.StdErr.Trim() } else { '' } $tail = if ($stderr) { ": $stderr" } else { '.' } throw [AvmProcessException]::new( ('mapotf transform exited with code {0}{1}' -f $transform.ExitCode, $tail)) } $clean = Invoke-AvmProcess ` -FilePath $tool.Path ` -ArgumentList @('clean-backup', '--tf-dir', $Context.Root) ` -WorkingDirectory $Context.Root ` -EnvVars $mapotfEnv ` -IgnoreExitCode if ($clean.ExitCode -ne 0) { $stderr = if ($clean.StdErr) { $clean.StdErr.Trim() } else { '' } $tail = if ($stderr) { ": $stderr" } else { '.' } throw [AvmProcessException]::new( ('mapotf clean-backup exited with code {0}{1}' -f $clean.ExitCode, $tail)) } $afterFiles = Get-AvmTerraformFile -Root $Context.Root $seen = New-Object 'System.Collections.Generic.HashSet[string]' $changed = New-Object System.Collections.Generic.List[string] foreach ($f in $afterFiles) { $null = $seen.Add($f.FullName) $rel = [System.IO.Path]::GetRelativePath($Context.Root, $f.FullName) $hash = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256).Hash if (-not $before.ContainsKey($f.FullName)) { $changed.Add($rel) } elseif ($before[$f.FullName] -ne $hash) { $changed.Add($rel) } } foreach ($key in $before.Keys) { if (-not $seen.Contains($key)) { $changed.Add([System.IO.Path]::GetRelativePath($Context.Root, $key)) } } $status = 'pass' $issues = New-Object System.Collections.Generic.List[object] if ($CheckDrift -and $changed.Count -gt 0) { $status = 'fail' foreach ($rel in $changed) { $issues.Add([pscustomobject][ordered]@{ File = $rel Line = 0 Column = 0 Severity = 'error' Code = 'avm.tf.mapotf-drift' Message = ("mapotf transform modified '{0}'; run 'avm pre-commit -Ecosystem terraform' and commit the result." -f $rel) }) } } return [pscustomobject][ordered]@{ Engine = 'terraform' Tool = ('{0}/{1}' -f $tool.Name, $tool.Version) ToolPath = $tool.Path ToolSource = $tool.Source Status = $status FilesProcessed = $beforeFiles.Count Changed = $changed.ToArray() Issues = $issues.ToArray() } } |