Public/Get-AzLocalDeploymentStatus.ps1

Function Get-AzLocalDeploymentStatus {
    <#
    .SYNOPSIS
 
    Checks deployment status for all clusters defined in a CSV file.
 
    .DESCRIPTION
 
    Reads a cluster deployment CSV file and checks the current ARM deployment status
    for each cluster with ReadyToDeploy = TRUE. Reports the status of each cluster
    and generates JUnit XML output for CI/CD pipeline visibility.
 
    Designed to be called by a monitoring pipeline on a schedule (e.g., every 15 minutes)
    to track the progress of long-running Azure Local deployments.
 
    Status categories:
    - NotStarted: No deployment found for this cluster
    - ValidateInProgress: Validation deployment is running
    - ValidateSucceeded: Validation completed successfully (ready for Deploy)
    - ValidateFailed: Validation failed
    - ValidateCanceled: Validation was canceled
    - DeployInProgress: Deploy deployment is running
    - DeploySucceeded: Deployment completed successfully
    - DeployFailed: Deployment failed
    - DeployCanceled: Deployment was canceled
    - ClusterExists: Cluster resource already exists
    - ContextError: Azure subscription/tenant context could not be established
    - CheckError: Azure resource or deployment status lookup failed
 
    .PARAMETER CsvFilePath
    Path to the cluster deployments CSV file.
 
    .PARAMETER JUnitOutputPath
    Optional. Path to write JUnit XML test results.
 
    .PARAMETER LogFilePath
    Optional. Path to a log file for diagnostic output.
 
    .PARAMETER HtmlOutputPath
    Optional. Path to write an HTML deployment status report.
 
    .PARAMETER MarkdownOutputPath
    Optional. Path to write a Markdown deployment status report (for GitHub Step Summary or Azure DevOps).
 
    .PARAMETER ReportTitle
    Optional. Title displayed in the HTML/Markdown report header.
    Default: 'Azure Local Deployment Status Report'.
 
    .EXAMPLE
    Get-AzLocalDeploymentStatus -CsvFilePath './automation-pipelines/cluster-deployments.csv'
 
    .EXAMPLE
    Get-AzLocalDeploymentStatus -CsvFilePath './automation-pipelines/cluster-deployments.csv' -JUnitOutputPath './reports/status.xml'
 
    .EXAMPLE
    Get-AzLocalDeploymentStatus -CsvFilePath './automation-pipelines/cluster-deployments.csv' -HtmlOutputPath './reports/status.html' -MarkdownOutputPath './reports/status.md'
 
    #>


    [OutputType([PSCustomObject[]])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true, Position = 0)]
        [string]$CsvFilePath,

        [Parameter(Mandatory = $false)]
        [string]$JUnitOutputPath = "",

        [Parameter(Mandatory = $false)]
        [string]$LogFilePath = "",

        [Parameter(Mandatory = $false)]
        [string]$HtmlOutputPath = "",

        [Parameter(Mandatory = $false)]
        [string]$MarkdownOutputPath = "",

        [Parameter(Mandatory = $false)]
        [string]$ReportTitle = "Azure Local Deployment Status Report",

        # --- Optional: path to a custom naming-standards-config.json file ---
        # (Overrides the default user profile and module config file resolution)
        [Parameter(Mandatory = $false)]
        [string]$NamingConfigPath = ""
    )

    # Reset module-scoped log path (prevents bleed-over from previous function calls)
    $script:AzLocalLogFilePath = $null

    # Initialise log file if specified
    if (-not [string]::IsNullOrWhiteSpace($LogFilePath)) {
        Initialize-AzLocalLogFile -LogFilePath $LogFilePath
    }

    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp
    Write-AzLocalLog " Deployment Status Monitor" -Level Info -NoTimestamp
    Write-AzLocalLog " CSV File: $CsvFilePath" -Level Info -NoTimestamp
    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp

    # Load naming configuration (user profile, explicit path, or module default)
    $configResult = Get-AzLocalNamingConfig -Path $NamingConfigPath
    $NamingConfig = $configResult.Config
    $resolvedConfigPath = $configResult.ResolvedPath

    # Validate the config has been customised from shipped defaults
    Test-AzLocalNamingConfigDefaults -Config $NamingConfig -ConfigFilePath $resolvedConfigPath

    # Import CSV (ReadyToDeploy = TRUE only)
    # Wrap in @() to ensure array even for single-row CSV (PS 5.1 + StrictMode compatibility)
    $clusters = @(Import-AzLocalDeploymentCsv -CsvFilePath $CsvFilePath -ReadyOnly)

    if ($clusters.Count -eq 0) {
        Write-AzLocalLog "No clusters with ReadyToDeploy = TRUE found in CSV." -Level Warning
        return @()
    }

    Write-AzLocalLog "Checking status for $($clusters.Count) cluster(s)." -Level Info

    $allResults = New-Object 'System.Collections.Generic.List[object]'

    foreach ($cluster in $clusters) {
        $uniqueID = $cluster.UniqueID
        $startTime = Get-Date

        # Resolve resource names
        $resourceGroupName = Resolve-AzLocalResourceName -Pattern $NamingConfig.namingStandards.resourceGroupName -UniqueID $uniqueID
        $clusterName = Resolve-AzLocalResourceName -Pattern $NamingConfig.namingStandards.clusterName -UniqueID $uniqueID
        $deploymentName = Resolve-AzLocalResourceName -Pattern $NamingConfig.namingStandards.deploymentName -UniqueID $uniqueID -TypeOfDeployment $cluster.TypeOfDeployment

        Write-AzLocalLog "Checking: $uniqueID (RG: $resourceGroupName, Deployment: $deploymentName)" -Level Info

        # Set subscription context
        try {
            Set-AzContext -SubscriptionId $cluster.SubscriptionId -TenantId $cluster.TenantId -ErrorAction Stop | Out-Null
        } catch {
            $allResults.Add([PSCustomObject]@{
                UniqueID          = $uniqueID
                ClusterName       = $clusterName
                ResourceGroupName = $resourceGroupName
                DeploymentName    = $deploymentName
                DeploymentStatus  = 'ContextError'
                ProvisioningState = 'N/A'
                Message           = "Failed to set Azure context: $($_.Exception.Message)"
                Duration          = 0
            }) | Out-Null
            continue
        }

        # Check if cluster already exists
        $clusterResourceId = "/subscriptions/$($cluster.SubscriptionId)/resourceGroups/$resourceGroupName/providers/Microsoft.AzureStackHCI/clusters/$clusterName"
        $existingCluster = $null
        try {
            # Existence-aware lookup: $null means genuinely absent; a real failure
            # (unsupported api-version, auth, RBAC, transport) throws with the real
            # reason instead of being masked as "does not exist".
            $existingCluster = Get-AzLocalArmResource -ResourceId $clusterResourceId -ResourceKind 'cluster'
        } catch {
            $duration = ((Get-Date) - $startTime).TotalSeconds
            Write-AzLocalLog " ${uniqueID}: Error checking cluster existence - $($_.Exception.Message)" -Level Error
            $allResults.Add([PSCustomObject]@{
                UniqueID          = $uniqueID
                ClusterName       = $clusterName
                ResourceGroupName = $resourceGroupName
                DeploymentName    = $deploymentName
                DeploymentStatus  = 'CheckError'
                ProvisioningState = 'N/A'
                Message           = "Failed to check cluster existence: $($_.Exception.Message)"
                Duration          = [math]::Round($duration, 2)
            }) | Out-Null
            continue
        }
        if ($existingCluster) {
            $duration = ((Get-Date) - $startTime).TotalSeconds
            Write-AzLocalLog " ${uniqueID}: Cluster already exists." -Level Success
            $allResults.Add([PSCustomObject]@{
                UniqueID          = $uniqueID
                ClusterName       = $clusterName
                ResourceGroupName = $resourceGroupName
                DeploymentName    = $deploymentName
                DeploymentStatus  = 'ClusterExists'
                ProvisioningState = 'Succeeded'
                Message           = "Cluster '$clusterName' exists in resource group '$resourceGroupName'."
                Duration          = [math]::Round($duration, 2)
            }) | Out-Null
            continue
        }

        # Check resource group exists
        $rg = $null
        try {
            $rg = Get-AzResourceGroup -Name $resourceGroupName -ErrorAction Stop
        } catch {
            if (-not (Test-AzLocalNotFoundError -ErrorRecord $_ -ErrorCode @('ResourceGroupNotFound', 'ResourceNotFound'))) {
                $duration = ((Get-Date) - $startTime).TotalSeconds
                Write-AzLocalLog " ${uniqueID}: Error checking resource group - $($_.Exception.Message)" -Level Error
                $allResults.Add([PSCustomObject]@{
                    UniqueID          = $uniqueID
                    ClusterName       = $clusterName
                    ResourceGroupName = $resourceGroupName
                    DeploymentName    = $deploymentName
                    DeploymentStatus  = 'CheckError'
                    ProvisioningState = 'N/A'
                    Message           = "Failed to check resource group existence: $($_.Exception.Message)"
                    Duration          = [math]::Round($duration, 2)
                }) | Out-Null
                continue
            }
        }
        if (-not $rg) {
            $duration = ((Get-Date) - $startTime).TotalSeconds
            Write-AzLocalLog " ${uniqueID}: Resource group not found." -Level Warning
            $allResults.Add([PSCustomObject]@{
                UniqueID          = $uniqueID
                ClusterName       = $clusterName
                ResourceGroupName = $resourceGroupName
                DeploymentName    = $deploymentName
                DeploymentStatus  = 'NotStarted'
                ProvisioningState = 'N/A'
                Message           = "Resource group '$resourceGroupName' does not exist."
                Duration          = [math]::Round($duration, 2)
            }) | Out-Null
            continue
        }

        # Check deployment status
        $deployment = $null
        try {
            $deployment = Get-AzResourceGroupDeployment -ResourceGroupName $resourceGroupName -Name $deploymentName -ErrorAction Stop
        } catch {
            if (-not (Test-AzLocalNotFoundError -ErrorRecord $_ -ErrorCode @('ResourceGroupDeploymentNotFound', 'DeploymentNotFound', 'ResourceNotFound'))) {
                $duration = ((Get-Date) - $startTime).TotalSeconds
                Write-AzLocalLog " ${uniqueID}: Error checking deployment status - $($_.Exception.Message)" -Level Error
                $allResults.Add([PSCustomObject]@{
                    UniqueID          = $uniqueID
                    ClusterName       = $clusterName
                    ResourceGroupName = $resourceGroupName
                    DeploymentName    = $deploymentName
                    DeploymentStatus  = 'CheckError'
                    ProvisioningState = 'N/A'
                    Message           = "Failed to check deployment status: $($_.Exception.Message)"
                    Duration          = [math]::Round($duration, 2)
                }) | Out-Null
                continue
            }
        }
        $duration = ((Get-Date) - $startTime).TotalSeconds

        if (-not $deployment) {
            Write-AzLocalLog " ${uniqueID}: No deployment found." -Level Info
            $allResults.Add([PSCustomObject]@{
                UniqueID          = $uniqueID
                ClusterName       = $clusterName
                ResourceGroupName = $resourceGroupName
                DeploymentName    = $deploymentName
                DeploymentStatus  = 'NotStarted'
                ProvisioningState = 'N/A'
                Message           = "No deployment '$deploymentName' found."
                Duration          = [math]::Round($duration, 2)
            }) | Out-Null
            continue
        }

        $provState = $deployment.ProvisioningState
        $deploymentDuration = if ($deployment.PSObject.Properties['Duration'] -and $deployment.Duration) { $deployment.Duration.ToString() } else { "N/A" }

        # Determine deployment status category
        # Check the deploymentMode parameter in the deployment to know if it was a Validate or Deploy
        $deployedMode = 'Unknown'
        try {
            $modeParam = $deployment.Parameters['deploymentMode']
            if ($modeParam) { $deployedMode = $modeParam.Value }
        } catch { }

        $statusCategory = switch ($provState) {
            'Running'   { if ($deployedMode -eq 'Deploy') { 'DeployInProgress' } else { 'ValidateInProgress' } }
            'Accepted'  { if ($deployedMode -eq 'Deploy') { 'DeployInProgress' } else { 'ValidateInProgress' } }
            'Succeeded' { if ($deployedMode -eq 'Deploy') { 'DeploySucceeded' }  else { 'ValidateSucceeded' } }
            'Failed'    { if ($deployedMode -eq 'Deploy') { 'DeployFailed' }     else { 'ValidateFailed' } }
            'Canceled'  { if ($deployedMode -eq 'Deploy') { 'DeployCanceled' }   else { 'ValidateCanceled' } }
            default     { $provState }
        }

        $levelColour = switch ($statusCategory) {
            'ValidateSucceeded' { 'Success' }
            'DeploySucceeded'   { 'Success' }
            'ValidateInProgress' { 'Warning' }
            'DeployInProgress'  { 'Warning' }
            default             { 'Error' }
        }

        Write-AzLocalLog " ${uniqueID}: $statusCategory (ARM Duration: $deploymentDuration)" -Level $levelColour

        $allResults.Add([PSCustomObject]@{
            UniqueID          = $uniqueID
            ClusterName       = $clusterName
            ResourceGroupName = $resourceGroupName
            DeploymentName    = $deploymentName
            DeploymentStatus  = $statusCategory
            ProvisioningState = $provState
            Message           = "Deployment '$deploymentName' state: $provState (Mode: $deployedMode, Duration: $deploymentDuration)"
            Duration          = [math]::Round($duration, 2)
        }) | Out-Null
    }

    # Generate JUnit XML report
    if (-not [string]::IsNullOrWhiteSpace($JUnitOutputPath)) {
        $junitResults = foreach ($r in $allResults) {
            $testStatus = switch ($r.DeploymentStatus) {
                'DeploySucceeded'    { 'Passed' }
                'ValidateSucceeded'  { 'Passed' }
                'ClusterExists'      { 'Passed' }
                'NotStarted'         { 'Skipped' }
                'ValidateInProgress' { 'Skipped' }
                'DeployInProgress'   { 'Skipped' }
                default              { 'Failed' }
            }
            [PSCustomObject]@{
                TestName  = "Status-$($r.UniqueID)"
                ClassName = "AzLocalDeploymentAutomation.Monitor"
                Status    = $testStatus
                Message   = $r.Message
                Duration  = $r.Duration
            }
        }
        New-AzLocalJUnitXml -TestResults $junitResults -SuiteName 'AzLocalDeployment-Monitor' -OutputPath $JUnitOutputPath
    }

    # Generate HTML / Markdown reports
    if (-not [string]::IsNullOrWhiteSpace($HtmlOutputPath) -or -not [string]::IsNullOrWhiteSpace($MarkdownOutputPath)) {
        New-AzLocalDeploymentReport -StatusResults $allResults `
            -HtmlOutputPath $HtmlOutputPath `
            -MarkdownOutputPath $MarkdownOutputPath `
            -ReportTitle $ReportTitle | Out-Null
    }

    # Summary
    $statusGroups = $allResults | Group-Object -Property DeploymentStatus
    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp
    Write-AzLocalLog " Deployment Status Summary" -Level Info -NoTimestamp
    foreach ($group in $statusGroups) {
        Write-AzLocalLog " $($group.Name): $($group.Count)" -Level Info -NoTimestamp
    }
    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp

    return $allResults.ToArray()
}