Public/Deploy-TaxEditorInfra.ps1

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

function Deploy-TaxEditorInfra {
    <#
    .SYNOPSIS
        Deploys Bicep infrastructure from a specific git commit.
    .DESCRIPTION
        Checks out deploy/azure/main.bicep from the specified git commit,
        runs a what-if preview, then deploys with -Confirm gate. This
        enables infrastructure rollback to a known-good Bicep state.
 
        Some Bicep changes are irreversible (storage account deletion,
        Key Vault purge). The what-if preview and -Confirm gate protect
        against accidents. Supports -WhatIf/-Confirm.
    .PARAMETER Commit
        Git commit SHA (full or short) to deploy from.
    .PARAMETER ResourceGroup
        Azure resource group name. Default: ai-triad.
    .PARAMETER Parameters
        Additional Bicep parameters as a hashtable.
    .EXAMPLE
        Deploy-TaxEditorInfra -Commit 'abc1234'
    .EXAMPLE
        Deploy-TaxEditorInfra -Commit 'abc1234' -WhatIf
    .EXAMPLE
        Deploy-TaxEditorInfra -Commit 'abc1234' -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
        Test-TaxEditorInfra
    .LINK
        Deploy-TaxEditorImage
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param(
        [Parameter(Mandatory, Position = 0)]
        [string]$Commit,

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

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

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

    # ── Validate az CLI ──────────────────────────────────────────────────
    $AzCmd = Get-Command az -ErrorAction SilentlyContinue
    if (-not $AzCmd) {
        throw (New-ActionableError `
            -Goal 'Deploy Bicep infrastructure' `
            -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 'Deploy Bicep infrastructure' `
            -Problem 'Azure CLI is not logged in' `
            -Location $CallerName `
            -NextSteps @('Run: az login', 'Verify subscription: az account show'))
    }

    # ── Validate git and resolve commit ──────────────────────────────────
    $GitCmd = Get-Command git -ErrorAction SilentlyContinue
    if (-not $GitCmd) {
        throw (New-ActionableError `
            -Goal 'Retrieve Bicep from commit' `
            -Problem 'git not found on PATH' `
            -Location $CallerName `
            -NextSteps @('Install git: https://git-scm.com'))
    }

    $FullSha = & git rev-parse --verify "$Commit" 2>$null
    if ($LASTEXITCODE -ne 0 -or -not $FullSha) {
        throw (New-ActionableError `
            -Goal 'Retrieve Bicep from commit' `
            -Problem "Commit '$Commit' not found in repository" `
            -Location $CallerName `
            -NextSteps @('Verify the commit SHA: git log --oneline',
                         'Fetch from remote if needed: git fetch origin'))
    }
    $ShortSha = $FullSha.Substring(0, [Math]::Min(10, $FullSha.Length))

    # ── Extract Bicep from commit ────────────────────────────────────────
    $BicepContent = & git show "${FullSha}:deploy/azure/main.bicep" 2>$null
    if ($LASTEXITCODE -ne 0 -or -not $BicepContent) {
        throw (New-ActionableError `
            -Goal 'Retrieve Bicep from commit' `
            -Problem "deploy/azure/main.bicep not found at commit $ShortSha" `
            -Location $CallerName `
            -NextSteps @("Verify the file exists at that commit: git show ${ShortSha}:deploy/azure/main.bicep",
                         'The Bicep file may have been added after this commit'))
    }

    $TempFile = [System.IO.Path]::GetTempFileName() -replace '\.tmp$', '.bicep'
    try {
        [System.IO.File]::WriteAllText($TempFile, ($BicepContent -join "`n"))

        # ── Run what-if preview ──────────────────────────────────────────
        Write-Verbose "Running what-if preview for commit $ShortSha..."
        $AzWhatIfArgs = @(
            'deployment', 'group', 'what-if'
            '--resource-group', $ResourceGroup
            '--template-file', $TempFile
            '--no-pretty-print'
        )

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

        $WhatIfOutput = & az @AzWhatIfArgs 2>&1 | Out-String

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

        # ── Parse change count ───────────────────────────────────────────
        $ChangeCount = 0
        try {
            $JsonOutput = $WhatIfOutput | ConvertFrom-Json -ErrorAction Stop
            if ($JsonOutput.PSObject.Properties['changes']) {
                $ChangeCount = @($JsonOutput.changes).Count
            }
        }
        catch {
            Write-Verbose 'Could not parse what-if JSON output.'
        }

        Write-Verbose "What-if preview: $ChangeCount change(s) detected."

        # ── WhatIf / Confirm gate ────────────────────────────────────────
        $ConfirmMsg = "Deploy Bicep from commit $ShortSha ($ChangeCount change(s)"
        if ($HasDeletes) { $ConfirmMsg += ', includes DELETIONS' }
        $ConfirmMsg += ')'

        if (-not $PSCmdlet.ShouldProcess($ResourceGroup, $ConfirmMsg)) {
            return [PSCustomObject]@{
                Action        = 'InfraDeployPreview'
                Commit        = $ShortSha
                ResourceGroup = $ResourceGroup
                HasDeletes    = $HasDeletes
                ChangeCount   = $ChangeCount
                Deployed      = $false
                WhatIfOutput  = $WhatIfOutput.Trim()
                Timestamp     = (Get-Date).ToString('o')
            }
        }

        # ── Deploy ───────────────────────────────────────────────────────
        Write-Verbose "Deploying Bicep from commit $ShortSha..."
        $AzDeployArgs = @(
            'deployment', 'group', 'create'
            '--resource-group', $ResourceGroup
            '--template-file', $TempFile
            '--output', 'json'
        )

        if ($Parameters.Count -gt 0) {
            $AzDeployArgs += '--parameters'
            $AzDeployArgs += $ParamArgs
        }

        $DeployOutput = & az @AzDeployArgs 2>$null | Out-String

        if ($LASTEXITCODE -ne 0) {
            throw (New-ActionableError `
                -Goal "Deploy Bicep from commit $ShortSha" `
                -Problem "az deployment group create failed (exit code $LASTEXITCODE)" `
                -Location $CallerName `
                -NextSteps @("Review the what-if output for issues",
                             "Check deployment status: az deployment group list -g $ResourceGroup --query `"[0]`""))
        }

        $ProvisioningState = 'Unknown'
        try {
            $DeployJson = $DeployOutput | ConvertFrom-Json -ErrorAction Stop
            if ($DeployJson.PSObject.Properties['properties'] -and
                $DeployJson.properties.PSObject.Properties['provisioningState']) {
                $ProvisioningState = $DeployJson.properties.provisioningState
            }
        }
        catch {
            Write-Verbose 'Could not parse deployment JSON output.'
        }

        Write-Verbose "Deployment complete: $ProvisioningState"

        [PSCustomObject]@{
            Action            = 'InfraDeploy'
            Commit            = $ShortSha
            ResourceGroup     = $ResourceGroup
            HasDeletes        = $HasDeletes
            ChangeCount       = $ChangeCount
            Deployed          = $true
            ProvisioningState = $ProvisioningState
            Timestamp         = (Get-Date).ToString('o')
        }
    }
    finally {
        if (Test-Path $TempFile) {
            Remove-Item $TempFile -Force -ErrorAction SilentlyContinue
        }
    }
}