Public/Test-TaxEditorInfra.ps1

# Copyright (c) 2026 Jeffrey Snover. All rights reserved.
# Licensed under the MIT License. See LICENSE file in the project root.

function Test-TaxEditorInfra {
    <#
    .SYNOPSIS
        Runs a Bicep what-if dry run to preview infrastructure changes.
    .DESCRIPTION
        Executes 'az deployment group what-if' against the current
        deploy/azure/main.bicep template. Shows what would change without
        deploying. Highlights resource deletions as warnings.
    .PARAMETER ResourceGroup
        Azure resource group name. Default: ai-triad.
    .PARAMETER TemplatePath
        Path to the Bicep template. Default: deploy/azure/main.bicep (relative to repo root).
    .PARAMETER Parameters
        Additional Bicep parameters as a hashtable (e.g., @{ containerImage = 'ghcr.io/...:0.8.1' }).
    .EXAMPLE
        Test-TaxEditorInfra
    .EXAMPLE
        Test-TaxEditorInfra -Parameters @{ containerImage = 'ghcr.io/jpsnover/taxonomy-editor:0.8.0' }
    .LINK
        Show-AITriadHelp
    .LINK
        Test-AzureHealth
    .LINK
        Get-ContainerAppRevision
    .LINK
        Remove-StaleContainerImages
    .LINK
        Get-AzureFlightRecorder
    .LINK
        Deploy-TaxEditorInfra
    .LINK
        Deploy-TaxEditorImage
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$ResourceGroup = 'ai-triad',

        [Parameter()]
        [string]$TemplatePath,

        [Parameter()]
        [hashtable]$Parameters = @{}
    )

    Set-StrictMode -Version Latest
    $CallerName = 'Test-TaxEditorInfra'

    # ── Validate az CLI ──────────────────────────────────────────────────
    $AzCmd = Get-Command az -ErrorAction SilentlyContinue
    if (-not $AzCmd) {
        throw (New-ActionableError `
            -Goal 'Run Bicep what-if preview' `
            -Problem 'Azure CLI (az) not found on PATH' `
            -Location $CallerName `
            -NextSteps @('Install Azure CLI: https://aka.ms/installazurecli'))
    }

    $AccountJson = & az account show --output json 2>$null
    if ($LASTEXITCODE -ne 0 -or -not $AccountJson) {
        throw (New-ActionableError `
            -Goal 'Run Bicep what-if preview' `
            -Problem 'Azure CLI is not logged in' `
            -Location $CallerName `
            -NextSteps @('Run: az login', 'Verify subscription: az account show'))
    }

    # ── Resolve template path ────────────────────────────────────────────
    if (-not $TemplatePath) {
        $RepoRoot = (git rev-parse --show-toplevel 2>$null)
        if (-not $RepoRoot) {
            throw (New-ActionableError `
                -Goal 'Locate Bicep template' `
                -Problem 'Not in a git repository — cannot resolve default template path' `
                -Location $CallerName `
                -NextSteps @('Pass -TemplatePath explicitly',
                             'Run from inside the ai-triad-research repository'))
        }
        $TemplatePath = Join-Path $RepoRoot 'deploy' 'azure' 'main.bicep'
    }

    if (-not (Test-Path $TemplatePath)) {
        throw (New-ActionableError `
            -Goal 'Run Bicep what-if preview' `
            -Problem "Bicep template not found: $TemplatePath" `
            -Location $CallerName `
            -NextSteps @('Verify the path to main.bicep',
                         'Default: deploy/azure/main.bicep in repo root'))
    }

    # ── Build az command arguments ───────────────────────────────────────
    $AzArgs = @(
        'deployment', 'group', 'what-if'
        '--resource-group', $ResourceGroup
        '--template-file', $TemplatePath
        '--no-pretty-print'
    )

    if ($Parameters.Count -gt 0) {
        $ParamArgs = @()
        foreach ($Key in $Parameters.Keys) {
            $ParamArgs += "${Key}=$($Parameters[$Key])"
        }
        $AzArgs += '--parameters'
        $AzArgs += $ParamArgs
    }

    # ── Run what-if ──────────────────────────────────────────────────────
    Write-Verbose "Running: az $($AzArgs -join ' ')"
    $RawOutput = & az @AzArgs 2>&1 | Out-String

    $HasDeletes = $RawOutput -match '"changeType"\s*:\s*"Delete"'
    if ($HasDeletes) {
        Write-Warning 'Bicep what-if detected resource DELETIONS — review carefully before deploying.'
    }

    # ── Parse changes from output ────────────────────────────────────────
    $Changes = @()
    try {
        $JsonOutput = $RawOutput | ConvertFrom-Json -ErrorAction Stop
        if ($JsonOutput.PSObject.Properties['changes']) {
            foreach ($Change in $JsonOutput.changes) {
                $ResourceId = ''
                if ($Change.PSObject.Properties['resourceId']) {
                    $ResourceId = $Change.resourceId
                }
                $ChangeType = ''
                if ($Change.PSObject.Properties['changeType']) {
                    $ChangeType = $Change.changeType
                }
                $Changes += [PSCustomObject]@{
                    ChangeType = $ChangeType
                    ResourceId = $ResourceId
                }
            }
        }
    }
    catch {
        Write-Verbose "Could not parse what-if JSON output — returning raw text."
    }

    # ── Return result ────────────────────────────────────────────────────
    [PSCustomObject]@{
        Action        = 'InfraWhatIf'
        ResourceGroup = $ResourceGroup
        TemplatePath  = $TemplatePath
        HasDeletes    = $HasDeletes
        ChangeCount   = @($Changes).Count
        Changes       = $Changes
        RawOutput     = $RawOutput.Trim()
        Timestamp     = (Get-Date).ToString('o')
    }
}