LibreDevOpsHelpers.Terraform.AzureImport/LibreDevOpsHelpers.Terraform.AzureImport.psm1
|
Set-StrictMode -Version Latest $script:LdoSubscriptionIdCache = $null function Get-LdoTerraformImportResourceId { <# .SYNOPSIS Resolves the ARM resource id for a Terraform resource from its planned attributes. .DESCRIPTION Maps common azurerm_* resource types to their ARM id using the planned 'after' attributes, falling back to 'az resource list' and finally Azure Resource Graph. The subscription id is resolved once via 'az account show' and cached for the session. Returns $null when no id can be resolved. Requires the Azure CLI to be signed in. .PARAMETER TfType The Terraform resource type, for example azurerm_resource_group. .PARAMETER After The planned resource attributes (the change.after object from a plan). .PARAMETER SubscriptionId Subscription id to use. Defaults to the signed-in subscription (cached). .EXAMPLE Get-LdoTerraformImportResourceId -TfType azurerm_resource_group -After $after .OUTPUTS System.String. The ARM resource id, or $null when not resolvable. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$TfType, [Parameter(Mandatory)][psobject]$After, [string]$SubscriptionId ) try { # Read name defensively: under StrictMode a missing property throws, and some resources # (for example NSG associations) carry no 'name' in their planned attributes. $afterName = if ($After.PSObject.Properties['name']) { $After.name } else { '<unnamed>' } Write-LdoLog -Level INFO -Message "Resolving ARM id for $TfType/$afterName" if (-not $SubscriptionId) { if (-not $script:LdoSubscriptionIdCache) { Assert-LdoCommand -Name 'az' $sub = & az account show --query id -o tsv Assert-LdoLastExitCode -Operation 'az account show' if ([string]::IsNullOrWhiteSpace($sub)) { throw 'Could not resolve the Azure subscription id; is the Azure CLI signed in?' } $script:LdoSubscriptionIdCache = $sub.Trim() } $SubscriptionId = $script:LdoSubscriptionIdCache } $TypeMap = @{ azurerm_resource_group = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.name)" } azurerm_storage_account = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Storage/storageAccounts/$($After.name)" } azurerm_storage_container = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Storage/storageAccounts/$($After.storage_account_name)/blobServices/default/containers/$($After.name)" } azurerm_virtual_network = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Network/virtualNetworks/$($After.name)" } azurerm_subnet = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Network/virtualNetworks/$($After.virtual_network_name)/subnets/$($After.name)" } azurerm_user_assigned_identity = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.ManagedIdentity/userAssignedIdentities/$($After.name)" } azurerm_network_security_group = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Network/networkSecurityGroups/$($After.name)" } azurerm_network_security_rule = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Network/networkSecurityGroups/$($After.network_security_group_name)/securityRules/$($After.name)" } azurerm_subnet_network_security_group_association = { $After.subnet_id } azurerm_key_vault = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.KeyVault/vaults/$($After.name)" } azurerm_private_endpoint = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Network/privateEndpoints/$($After.name)" } azurerm_databricks_workspace = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Databricks/workspaces/$($After.name)" } azurerm_windows_function_app = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Web/sites/$($After.name)" } azurerm_service_plan = { "/subscriptions/$SubscriptionId/resourceGroups/$($After.resource_group_name)/providers/Microsoft.Web/serverfarms/$($After.name)" } } if ($TypeMap.ContainsKey($TfType)) { return (& $TypeMap[$TfType]) } $azType = if ($TfType -eq 'azurerm_subnet_network_security_group_association') { 'Microsoft.Network/virtualNetworks/subnets' } else { $TfType -replace '^azurerm_', '' -replace '_', '/' } # Scope lookups by resource group when the plan provides one, so a same-named resource in # another group or subscription can't be matched and imported into state by mistake. $rg = if ($After.PSObject.Properties['resource_group_name']) { $After.resource_group_name } else { $null } Assert-LdoCommand -Name 'az' $listArgs = @('resource', 'list', '--name', $After.name, '--resource-type', $azType, '--query', '[0].id', '-o', 'tsv') if ($rg) { $listArgs += @('--resource-group', $rg) } Write-LdoLog -Level INFO -Message "az $($listArgs -join ' ')" $id = & az @listArgs if ($LASTEXITCODE -eq 0 -and $id) { return $id.Trim() } $clauses = @("name =~ '$($After.name)'", "subscriptionId =~ '$SubscriptionId'") if ($rg) { $clauses += "resourceGroup =~ '$rg'" } $kusto = 'Resources | where ' + ($clauses -join ' and ') + ' | take 1 | project id' Write-LdoLog -Level INFO -Message "az graph query (fallback) for $($After.name)" $graphId = & az graph query -q $kusto --first 1 --output tsv if ($LASTEXITCODE -eq 0 -and $graphId) { return $graphId.Trim() } return $null } catch { Write-LdoLog -Level ERROR -Message "ARM id lookup failed for ${TfType}: $_" return $null } } function Invoke-LdoTerraformImportFromPlan { <# .SYNOPSIS Imports existing Azure resources into Terraform state from a plan JSON file. .DESCRIPTION Reads a terraform show -json plan, finds managed resources scheduled for creation, resolves their ARM ids, confirms they exist in Azure, writes an import manifest CSV, and runs terraform import for each (parent resources first). Use -DryRun to log the import commands without executing them. Requires the Azure CLI to be signed in. .PARAMETER PlanJson Path to the plan JSON file produced by terraform show -json. .PARAMETER CodePath Terraform configuration folder to run imports in. Defaults to the current directory. .PARAMETER DryRun When set, logs the terraform import commands without executing them. .PARAMETER Manifest Path to write the import manifest CSV. Defaults to ./import-map.csv. .EXAMPLE Invoke-LdoTerraformImportFromPlan -PlanJson ./tfplan.plan.json -CodePath ./terraform -DryRun .OUTPUTS None #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$PlanJson, [string]$CodePath = '.', [switch]$DryRun, [string]$Manifest = './import-map.csv' ) Assert-LdoCommand -Name 'az', 'terraform' if (-not (Test-Path $CodePath -PathType Container)) { throw "Terraform code path not found: $CodePath" } # Fail loudly once if the Azure CLI is not signed in, rather than silently skipping every # resource when id resolution returns nothing. & az account show -o none 2>$null Assert-LdoLastExitCode -Operation 'az account show (is the Azure CLI signed in?)' try { Write-LdoLog -Level INFO -Message "Reading plan file $PlanJson" $plan = Get-Content -LiteralPath $PlanJson -Raw -ErrorAction Stop | ConvertFrom-Json } catch { Write-LdoLog -Level ERROR -Message "Cannot read or parse plan: $_" throw } $imports = @() foreach ($chg in $plan.resource_changes) { if ($chg.mode -ne 'managed') { continue } if ($chg.change.actions -notcontains 'create') { continue } $addr = $chg.address $after = $chg.change.after $type = $chg.type if ($type -eq 'azurerm_subnet_network_security_group_association') { if (-not $after.name -and ($addr -match '\["([^"]+)"\]')) { $after | Add-Member name $matches[1] } $sub = $plan.resource_changes | Where-Object { $_.type -eq 'azurerm_subnet' -and $_.change.after.name -eq $after.name } | Select-Object -First 1 if ($sub) { foreach ($p in 'resource_group_name', 'virtual_network_name') { if (-not $after.$p) { $after | Add-Member $p $sub.change.after.$p } } if (-not $after.subnet_id) { $sid = Get-LdoTerraformImportResourceId -TfType 'azurerm_subnet' -After $sub.change.after if ($sid) { $after | Add-Member subnet_id $sid } } } } try { $id = Get-LdoTerraformImportResourceId -TfType $type -After $after if (-not $id) { Write-LdoLog -Level WARN -Message "No ARM id for ${addr} (${type}); skipping." continue } Write-LdoLog -Level INFO -Message "Mapped ${addr} to ${id}" if ($type -ne 'azurerm_subnet_network_security_group_association') { $confirmId = az resource show --ids $id --query id -o tsv 2>$null if (-not $confirmId) { Write-LdoLog -Level WARN -Message "Azure reports ${addr} not found; skipping." continue } Write-LdoLog -Level INFO -Message "Confirmed existing resource for ${addr}" } $imports += [pscustomobject]@{ Address = $addr; Id = $id } } catch { Write-LdoLog -Level ERROR -Message "Lookup/import prep failed for ${addr}: $_" } } if (-not $imports) { Write-LdoLog -Level INFO -Message 'Nothing to import; plan has no importable Azure resources.' return } $imports = $imports | Sort-Object { (($_.Id -split '/').Count) } try { $imports | Export-Csv $Manifest -NoTypeInformation Write-LdoLog -Level INFO -Message "Wrote manifest to $Manifest" } catch { Write-LdoLog -Level ERROR -Message "Failed to write manifest: $_" } if ($DryRun) { foreach ($i in $imports) { Write-LdoLog -Level INFO -Message "[DRY-RUN] terraform import $($i.Address) $($i.Id)" } Write-LdoLog -Level SUCCESS -Message "[DRY-RUN] $($imports.Count) resource(s) would be imported." return } $importedCount = 0 $failedCount = 0 foreach ($i in $imports) { Push-Location $CodePath try { Write-LdoLog -Level INFO -Message "Importing $($i.Address)" # Address and id are passed as discrete arguments; resource addresses containing # brackets/quotes (e.g. foo["key"]) are safe as single array elements and need no # stop-parsing token. $output = & terraform import $i.Address $i.Id 2>&1 $output | ForEach-Object { Write-LdoLog -Level INFO -Message "terraform: $_" } Assert-LdoLastExitCode -Operation "terraform import $($i.Address)" Write-LdoLog -Level SUCCESS -Message "Imported $($i.Address)" $importedCount++ } catch { Write-LdoLog -Level ERROR -Message "terraform import failed for $($i.Address): $_" $failedCount++ } finally { Pop-Location } } if ($failedCount -gt 0) { throw "$failedCount of $($imports.Count) import(s) failed; see the log for details." } Write-LdoLog -Level SUCCESS -Message "Imported $importedCount resource(s)." } Export-ModuleMember -Function ` Get-LdoTerraformImportResourceId, ` Invoke-LdoTerraformImportFromPlan |