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 {
        Write-LdoLog -Level INFO -Message "Resolving ARM id for $TfType/$($After.name)"

        if (-not $SubscriptionId) {
            if (-not $script:LdoSubscriptionIdCache) {
                $script:LdoSubscriptionIdCache = & az account show --query id -o tsv
            }
            $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 '_', '/'
        }

        Write-LdoLog -Level INFO -Message "az resource list --name $($After.name) --resource-type $azType"
        $id = & az resource list --name $After.name --resource-type $azType --query '[0].id' -o tsv
        if ($id) {
            return $id
        }

        $kusto = "Resources | where name == '$($After.name)' | take 1 | project id"
        Write-LdoLog -Level INFO -Message "az graph query (fallback) for $($After.name)"
        return & az graph query -q $kusto --first 1 --output tsv
    }
    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'
    )

    try {
        Write-LdoLog -Level INFO -Message "Reading plan file $PlanJson"
        $plan = Get-Content $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: $_"
    }

    foreach ($i in $imports) {
        $cmdArgs = @('--%', $i.Address, $i.Id)
        if ($DryRun) {
            Write-LdoLog -Level INFO -Message "[DRY-RUN] terraform import $($cmdArgs -join ' ')"
            continue
        }

        try {
            Push-Location $CodePath
            Write-LdoLog -Level INFO -Message "Importing $($i.Address)"
            & terraform import @cmdArgs 2>&1
            Write-LdoLog -Level INFO -Message "Imported $($i.Address)"
        }
        catch {
            Write-LdoLog -Level ERROR -Message "terraform import failed for $($i.Address): $_"
        }
        finally {
            Pop-Location
        }
    }

    Write-LdoLog -Level SUCCESS -Message "Completed: $($imports.Count) resource(s) processed."
}

Export-ModuleMember -Function `
    Get-LdoTerraformImportResourceId, `
    Invoke-LdoTerraformImportFromPlan