LibreDevOpsHelpers.Terraform.GraphImport/LibreDevOpsHelpers.Terraform.GraphImport.psm1

Set-StrictMode -Version Latest

# The Microsoft Graph collections this importer can resolve, keyed by the msgraph_resource url
# (normalised without leading or trailing slashes). Custom detection rules are the first supported
# type; extend the table as more Graph estates move to code.
$script:LdoGraphImportSupportedUrls = @{
    'security/rules/detectionRules' = 'CustomDetectionRules'
}

function Get-LdoTerraformGraphImportResourceId {
    <#
    .SYNOPSIS
        Resolves the msgraph provider import id for a planned msgraph_resource.

    .DESCRIPTION
        The Graph sibling of Get-LdoTerraformImportResourceId. Maps a planned msgraph_resource
        (its url plus planned body) to an existing Graph object and returns the provider's import
        id format: "<url>/<id>?api-version=<version>". Matching prefers the planned body id (the
        client provided rule key), then falls back to the display name; an ambiguous display name
        match is refused rather than guessed. Returns $null when the url is unsupported or nothing
        matches.

    .PARAMETER Url
        The msgraph_resource collection url from the plan, for example security/rules/detectionRules.

    .PARAMETER After
        The planned resource attributes (the change.after object from a plan), carrying body and
        api_version.

    .PARAMETER ApiVersion
        API version used when the planned attributes do not carry one. Defaults to beta.

    .EXAMPLE
        Get-LdoTerraformGraphImportResourceId -Url 'security/rules/detectionRules' -After $after

    .OUTPUTS
        System.String. The import id, or $null when not resolvable.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Url,
        [Parameter(Mandatory)][psobject]$After,
        [ValidateSet('v1.0', 'beta')][string]$ApiVersion = 'beta'
    )

    $norm = $Url.Trim('/')
    if (-not $script:LdoGraphImportSupportedUrls.ContainsKey($norm)) {
        Write-LdoLog -Level WARN -Message "Unsupported Graph collection url '$norm'; supported: $($script:LdoGraphImportSupportedUrls.Keys -join ', ')."
        return $null
    }

    $ver = if ($After.PSObject.Properties['api_version'] -and $After.api_version) { $After.api_version } else { $ApiVersion }
    $body = if ($After.PSObject.Properties['body']) { $After.body } else { $null }

    $plannedId = if ($body -and $body.PSObject.Properties['id'] -and $body.id) { "$($body.id)" } else { $null }
    $plannedName = if ($body -and $body.PSObject.Properties['displayName'] -and $body.displayName) { "$($body.displayName)" } else { $null }

    $existing = @(Get-LdoCustomDetectionRule -ApiVersion $ver)

    $hit = $null
    if ($plannedId) {
        $hit = $existing | Where-Object { "$($_.id)" -eq $plannedId } | Select-Object -First 1
    }
    if (-not $hit -and $plannedName) {
        $named = @($existing | Where-Object { $_.displayName -eq $plannedName })
        if ($named.Count -gt 1) {
            Write-LdoLog -Level WARN -Message "Display name '$plannedName' matches $($named.Count) rules; refusing an ambiguous import (set the rule id to the server id instead)."
            return $null
        }
        $hit = $named | Select-Object -First 1
    }

    if (-not $hit) {
        Write-LdoLog -Level WARN -Message "No existing rule matches id '$plannedId' or display name '$plannedName'."
        return $null
    }

    return "$norm/$($hit.id)?api-version=$ver"
}

function Invoke-LdoTerraformGraphImportFromPlan {
    <#
    .SYNOPSIS
        Imports existing Microsoft Graph resources into Terraform state from a plan JSON file.

    .DESCRIPTION
        The Graph sibling of Invoke-LdoTerraformImportFromPlan and the second half of the
        brownfield detections as code flow: export existing rules with
        Export-LdoCustomDetectionRule, drop the YAML into the module's custom-detections layout,
        plan, then run this against the plan JSON. Managed msgraph_resource creations whose url is
        a supported Graph collection are matched to live objects (body id first, then display
        name), a manifest CSV is written, and terraform import runs for each (or is only logged
        with -DryRun). Resources that cannot be matched are skipped with a warning, never guessed.

    .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 ./graph-import-map.csv.

    .EXAMPLE
        Invoke-LdoTerraformGraphImportFromPlan -PlanJson ./tfplan.plan.json -CodePath . -DryRun

    .OUTPUTS
        None
    #>

    [CmdletBinding()]
    [OutputType([void])]
    param(
        [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$PlanJson,
        [string]$CodePath = '.',
        [switch]$DryRun,
        [string]$Manifest = './graph-import-map.csv'
    )

    # A dry run never invokes terraform, so the binary is only required when importing for real.
    if (-not $DryRun) {
        Assert-LdoCommand -Name 'terraform'
    }

    if (-not (Test-Path $CodePath -PathType Container)) {
        throw "Terraform code path not found: $CodePath"
    }

    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.type -ne 'msgraph_resource') { continue }
        if ($chg.change.actions -notcontains 'create') { continue }

        $addr = $chg.address
        $after = $chg.change.after
        $url = if ($after.PSObject.Properties['url'] -and $after.url) { "$($after.url)" } else { '' }

        if (-not $script:LdoGraphImportSupportedUrls.ContainsKey($url.Trim('/'))) {
            Write-LdoLog -Level INFO -Message "Skipping ${addr}: url '$url' is not a supported Graph import type."
            continue
        }

        try {
            $id = Get-LdoTerraformGraphImportResourceId -Url $url -After $after
            if (-not $id) {
                Write-LdoLog -Level WARN -Message "No live Graph object for ${addr}; skipping (it will be created on apply)."
                continue
            }
            Write-LdoLog -Level INFO -Message "Mapped ${addr} to ${id}"
            $imports += [pscustomobject]@{ Address = $addr; Id = $id }
        }
        catch {
            Write-LdoLog -Level ERROR -Message "Lookup failed for ${addr}: $_"
        }
    }

    if (-not $imports) {
        Write-LdoLog -Level INFO -Message 'Nothing to import; plan has no importable Graph resources.'
        return
    }

    $imports | Export-Csv -Path $Manifest -NoTypeInformation
    Write-LdoLog -Level INFO -Message "Wrote import manifest for $($imports.Count) resource(s) to $Manifest"

    $failed = 0
    foreach ($import in $imports) {
        if ($DryRun) {
            Write-LdoLog -Level INFO -Message "DRY RUN: terraform -chdir=$CodePath import '$($import.Address)' '$($import.Id)'"
            continue
        }
        Write-LdoLog -Level INFO -Message "Importing $($import.Address)"
        & terraform -chdir=$CodePath import $import.Address $import.Id
        if ($LASTEXITCODE -ne 0) {
            $failed++
            Write-LdoLog -Level ERROR -Message "terraform import failed for $($import.Address) (exit $LASTEXITCODE)."
        }
    }

    if ($failed -gt 0) {
        throw "Graph import: $failed of $($imports.Count) import(s) failed; see the log."
    }

    if (-not $DryRun) {
        Write-LdoLog -Level SUCCESS -Message "Imported $($imports.Count) Graph resource(s); run terraform plan to verify."
    }
}