Public/Deploy-TaxEditorImage.ps1

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

function Deploy-TaxEditorImage {
    <#
    .SYNOPSIS
        Deploys a specific GHCR image tag or digest via blue-green flow.
    .DESCRIPTION
        Creates a new ACA revision with the target image, shifts traffic,
        then runs a health check. If the health check fails, automatically
        rolls back to the previous revision. Supports -WhatIf/-Confirm.
    .PARAMETER Tag
        Image tag to deploy (e.g., '0.8.0', 'latest').
    .PARAMETER Digest
        Exact image digest to deploy (e.g., 'sha256:abc123...').
    .PARAMETER Registry
        Container registry + image path. Default: ghcr.io/jpsnover/taxonomy-editor.
    .PARAMETER ResourceGroup
        Azure resource group name. Default: ai-triad.
    .PARAMETER AppName
        Container App name. Default: taxonomy-editor.
    .PARAMETER BaseUrl
        URL for post-deploy health check. Default: production.
    .PARAMETER SkipHealthCheck
        Skip the post-deploy health check (no auto-rollback).
    .EXAMPLE
        Deploy-TaxEditorImage -Tag '0.8.0'
    .EXAMPLE
        Deploy-TaxEditorImage -Digest 'sha256:abc123...'
    .EXAMPLE
        Deploy-TaxEditorImage -Tag '0.8.0' -WhatIf
    .LINK
        Show-AITriadHelp
    .LINK
        Deploy-TaxEditorInfra
    .LINK
        Get-TaxEditorImage
    .LINK
        Get-TaxEditorRevision
    .LINK
        Switch-TaxEditorRevision
    .LINK
        Test-TaxEditorInfra
    .LINK
        Invoke-TaxEditorSmokeTest
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High',
                   DefaultParameterSetName = 'ByTag')]
    param(
        [Parameter(Mandatory, ParameterSetName = 'ByTag', Position = 0)]
        [string]$Tag,

        [Parameter(Mandatory, ParameterSetName = 'ByDigest')]
        [string]$Digest,

        [Parameter()]
        [string]$Registry = 'ghcr.io/jpsnover/taxonomy-editor',

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

        [Parameter()]
        [string]$AppName = 'taxonomy-editor',

        [Parameter()]
        [string]$BaseUrl = (Get-TaxEditorBaseUrl),

        [Parameter()]
        [switch]$SkipHealthCheck
    )

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

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

    # ── Construct image reference ────────────────────────────────────────
    $ImageRef = if ($Tag) { "${Registry}:${Tag}" } else { "${Registry}@${Digest}" }

    # ── WhatIf / Confirm gate ────────────────────────────────────────────
    if (-not $PSCmdlet.ShouldProcess($AppName, "Deploy image '$ImageRef' (blue-green)")) {
        return
    }

    # ── Get current active revisions for rollback ────────────────────────
    $OldRevisions = @(Get-TaxEditorRevision -ResourceGroup $ResourceGroup -AppName $AppName)
    $OldActive = @($OldRevisions | Where-Object { $_.Active })
    $OldActiveNames = @($OldActive | ForEach-Object { $_.Name })

    # ── Deploy new image ─────────────────────────────────────────────────
    Write-Verbose "Deploying image '$ImageRef'..."
    $RawJson = & az containerapp update -g $ResourceGroup -n $AppName `
        --image $ImageRef --output json 2>$null

    if ($LASTEXITCODE -ne 0 -or -not $RawJson) {
        throw (New-ActionableError `
            -Goal "Deploy image '$ImageRef'" `
            -Problem "az containerapp update failed (exit code $LASTEXITCODE)" `
            -Location $CallerName `
            -NextSteps @("Verify image exists: docker manifest inspect $ImageRef",
                         "Check app status: az containerapp show -g $ResourceGroup -n $AppName"))
    }

    $UpdateResult = $RawJson | ConvertFrom-Json
    $NewRevName = $UpdateResult.properties.latestRevisionName

    if (-not $NewRevName) {
        throw (New-ActionableError `
            -Goal "Deploy image '$ImageRef'" `
            -Problem 'Could not determine new revision name from update result' `
            -Location $CallerName `
            -NextSteps @('Run Get-TaxEditorRevision to inspect current state'))
    }

    Write-Verbose "New revision created: $NewRevName"

    # ── Shift traffic to new revision ────────────────────────────────────
    Write-Verbose "Shifting 100% traffic to '$NewRevName'..."
    & az containerapp ingress traffic set -g $ResourceGroup -n $AppName `
        --revision-weight "${NewRevName}=100" 2>$null

    if ($LASTEXITCODE -ne 0) {
        throw (New-ActionableError `
            -Goal "Shift traffic to new revision '$NewRevName'" `
            -Problem "az containerapp ingress traffic set failed (exit code $LASTEXITCODE)" `
            -Location $CallerName `
            -NextSteps @("Check revision status: az containerapp revision show -g $ResourceGroup -n $AppName --revision $NewRevName"))
    }

    # ── Deactivate old revisions ─────────────────────────────────────────
    foreach ($OldName in $OldActiveNames) {
        if ($OldName -ne $NewRevName) {
            Write-Verbose "Deactivating previous revision '$OldName'..."
            & az containerapp revision deactivate -g $ResourceGroup -n $AppName `
                --revision $OldName 2>$null
            if ($LASTEXITCODE -ne 0) {
                Write-Warning "Failed to deactivate revision '$OldName' — manual cleanup may be needed."
            }
        }
    }

    # ── Post-deploy health check ─────────────────────────────────────────
    $HealthResult = 'Skipped'
    if (-not $SkipHealthCheck) {
        Write-Verbose 'Running post-deploy health check...'
        $Health = Test-TaxEditorHealth -BaseUrl $BaseUrl

        if ($Health.Healthy) {
            $HealthResult = 'Passed'
            Write-Verbose "Health check passed (avg $($Health.AverageMs)ms)."
        }
        else {
            $HealthResult = 'Failed'
            Write-Warning 'Post-deploy health check FAILED — rolling back...'

            if (@($OldActiveNames).Count -gt 0) {
                $RollbackRev = $OldActiveNames[0]
                & az containerapp revision activate -g $ResourceGroup -n $AppName `
                    --revision $RollbackRev 2>$null
                & az containerapp ingress traffic set -g $ResourceGroup -n $AppName `
                    --revision-weight "${RollbackRev}=100" 2>$null
                & az containerapp revision deactivate -g $ResourceGroup -n $AppName `
                    --revision $NewRevName 2>$null
                Write-Warning "Rolled back to '$RollbackRev'. Run Test-TaxEditorHealth to verify."
            }
            else {
                Write-Warning 'No previous revision to roll back to — manual intervention required.'
            }
        }
    }

    # ── Return result ────────────────────────────────────────────────────
    [PSCustomObject]@{
        Action           = 'ImageDeploy'
        Image            = $ImageRef
        NewRevision      = $NewRevName
        PreviousRevision = if (@($OldActiveNames).Count -gt 0) { $OldActiveNames[0] } else { '(none)' }
        HealthCheck      = $HealthResult
        AppName          = $AppName
        ResourceGroup    = $ResourceGroup
        Timestamp        = (Get-Date).ToString('o')
    }
}