Public/Start-AzLocalCsvDeployment.ps1

Function Start-AzLocalCsvDeployment {
    <#
    .SYNOPSIS
 
    Drives Azure Local cluster deployments from a CSV file for CI/CD pipelines.
 
    .DESCRIPTION
 
    Reads a CSV file containing cluster deployment definitions, runs pre-flight checks
    (Arc node registration, resource group existence, naming validation, existing deployment
    detection), and then calls Start-AzLocalTemplateDeployment for each eligible cluster.
 
    Only clusters with ReadyToDeploy = TRUE are processed. Clusters that are already deployed,
    have deployments in-progress, or fail pre-flight checks are skipped with detailed reporting.
 
    Generates JUnit XML output for CI/CD pipeline test result visualization.
 
    .PARAMETER CsvFilePath
    Path to the cluster deployments CSV file.
 
    .PARAMETER DeploymentMode
    The ARM deployment mode: Validate (validate only) or Deploy (deploy only).
    Use separate pipeline stages for Validate then Deploy.
 
    .PARAMETER JUnitOutputPath
    Optional. Path to write JUnit XML test results.
 
    .PARAMETER LogFilePath
    Optional. Path to a log file for diagnostic output.
 
    .EXAMPLE
    Start-AzLocalCsvDeployment -CsvFilePath './automation-pipelines/cluster-deployments.csv' -DeploymentMode Validate
 
    .EXAMPLE
    Start-AzLocalCsvDeployment -CsvFilePath './automation-pipelines/cluster-deployments.csv' -DeploymentMode Deploy -JUnitOutputPath './reports/deploy-results.xml'
 
    #>


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

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateSet("Validate", "Deploy")]
        [string]$DeploymentMode,

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

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

    # 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 " CSV-Driven Deployment: $DeploymentMode" -Level Info -NoTimestamp
    Write-AzLocalLog " CSV File: $CsvFilePath" -Level Info -NoTimestamp
    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp

    # Load naming configuration
    $NamingConfig = Get-AzLocalNamingConfig

    # Import and validate 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
        $emptyResult = @([PSCustomObject]@{
            TestName  = 'NoClustersReady'
            ClassName = 'AzLocalDeploymentAutomation.PreFlight'
            Status    = 'Skipped'
            Message   = 'No clusters with ReadyToDeploy = TRUE in CSV file.'
            Duration  = 0
        })
        if (-not [string]::IsNullOrWhiteSpace($JUnitOutputPath)) {
            New-AzLocalJUnitXml -TestResults $emptyResult -SuiteName "AzLocalDeployment-$DeploymentMode" -OutputPath $JUnitOutputPath
        }
        return $emptyResult
    }

    Write-AzLocalLog "Processing $($clusters.Count) cluster(s) with ReadyToDeploy = TRUE." -Level Success

    $allResults = @()

    foreach ($cluster in $clusters) {
        $uniqueID = $cluster.UniqueID
        Write-AzLocalLog "--------------------------------------------------------" -Level Info -NoTimestamp
        Write-AzLocalLog " Processing: $uniqueID ($($cluster.TypeOfDeployment))" -Level Info -NoTimestamp
        Write-AzLocalLog "--------------------------------------------------------" -Level Info -NoTimestamp

        # Set subscription context
        try {
            Set-AzContext -SubscriptionId $cluster.SubscriptionId -TenantId $cluster.TenantId -ErrorAction Stop | Out-Null
            Write-AzLocalLog "Azure context set to subscription '$($cluster.SubscriptionId)'." -Level Success
        } catch {
            Write-AzLocalLog "Failed to set Azure context for ${uniqueID}: $($_.Exception.Message)" -Level Error
            $allResults += [PSCustomObject]@{
                TestName  = "PreFlight-$uniqueID"
                ClassName = "AzLocalDeploymentAutomation.PreFlight"
                Status    = 'Failed'
                Message   = "Failed to set Azure context: $($_.Exception.Message)"
                Duration  = 0
            }
            continue
        }

        # Run pre-flight checks
        $preFlightResult = Test-AzLocalClusterPreFlight -ClusterRow $cluster -NamingConfig $NamingConfig -DeploymentMode $DeploymentMode

        # Record pre-flight result
        $allResults += [PSCustomObject]@{
            TestName  = "PreFlight-$uniqueID"
            ClassName = "AzLocalDeploymentAutomation.PreFlight"
            Status    = $preFlightResult.Status
            Message   = ($preFlightResult.Messages -join "`n")
            Duration  = $preFlightResult.Duration
        }

        # Only proceed to deployment if pre-flight passed
        if ($preFlightResult.Status -ne 'Passed') {
            Write-AzLocalLog "Pre-flight $($preFlightResult.Status) for $uniqueID. Skipping deployment." -Level Warning
            continue
        }

        Write-AzLocalLog "Pre-flight PASSED for $uniqueID. Starting $DeploymentMode deployment..." -Level Success

        # Build network settings JSON from CSV columns
        $nodeIPs = @($cluster.NodeIPAddresses -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
        $networkJson = @{
            subnetMask        = $cluster.SubnetMask
            defaultGateway    = $cluster.DefaultGateway
            startingIPAddress = $cluster.StartingIPAddress
            endingIPAddress   = $cluster.EndingIPAddress
            nodeIPAddresses   = $nodeIPs
        } | ConvertTo-Json -Compress

        # Build deployment parameters
        $deployParams = @{
            SubscriptionId       = $cluster.SubscriptionId
            TypeOfDeployment     = $cluster.TypeOfDeployment
            TenantId             = $cluster.TenantId
            DeploymentMode       = $DeploymentMode
            UniqueID             = $uniqueID
            NetworkSettingsJson  = $networkJson
            CredentialKeyVaultName = $cluster.CredentialKeyVaultName
            SkipPreFlightChecks  = $true
            Confirm              = $false
        }

        # Optional parameters from CSV
        $nodeCount = [int]$cluster.NodeCount
        if ($nodeCount -gt 0) { $deployParams['NodeCount'] = $nodeCount }

        if ($cluster.PSObject.Properties['Location'] -and -not [string]::IsNullOrWhiteSpace($cluster.Location)) {
            $deployParams['Location'] = $cluster.Location
        }

        if ($cluster.PSObject.Properties['DnsServers'] -and -not [string]::IsNullOrWhiteSpace($cluster.DnsServers)) {
            $deployParams['DnsServers'] = @($cluster.DnsServers -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
        }

        if ($cluster.PSObject.Properties['LocalAdminSecretName'] -and -not [string]::IsNullOrWhiteSpace($cluster.LocalAdminSecretName)) {
            $deployParams['LocalAdminSecretName'] = $cluster.LocalAdminSecretName
        }

        if ($cluster.PSObject.Properties['LCMAdminSecretName'] -and -not [string]::IsNullOrWhiteSpace($cluster.LCMAdminSecretName)) {
            $deployParams['LCMAdminSecretName'] = $cluster.LCMAdminSecretName
        }

        if (-not [string]::IsNullOrWhiteSpace($LogFilePath)) {
            $deployParams['LogFilePath'] = $LogFilePath
        }

        # Execute deployment
        $deployStartTime = Get-Date
        try {
            if ($PSCmdlet.ShouldProcess("Cluster '$uniqueID'", "$DeploymentMode deployment")) {
                $deploymentResult = Start-AzLocalTemplateDeployment @deployParams
                $deployDuration = ((Get-Date) - $deployStartTime).TotalSeconds

                if ($deploymentResult -and $deploymentResult.ProvisioningState -eq 'Succeeded') {
                    $allResults += [PSCustomObject]@{
                        TestName  = "$DeploymentMode-$uniqueID"
                        ClassName = "AzLocalDeploymentAutomation.$DeploymentMode"
                        Status    = 'Passed'
                        Message   = "$DeploymentMode succeeded for cluster '$uniqueID'. Duration: $($deploymentResult.Duration)"
                        Duration  = [math]::Round($deployDuration, 2)
                    }
                    Write-AzLocalLog "$DeploymentMode SUCCEEDED for $uniqueID." -Level Success
                } else {
                    $provState = if ($deploymentResult) { $deploymentResult.ProvisioningState } else { "Unknown" }
                    $allResults += [PSCustomObject]@{
                        TestName  = "$DeploymentMode-$uniqueID"
                        ClassName = "AzLocalDeploymentAutomation.$DeploymentMode"
                        Status    = 'Failed'
                        Message   = "$DeploymentMode failed for cluster '$uniqueID'. ProvisioningState: $provState"
                        Duration  = [math]::Round($deployDuration, 2)
                    }
                    Write-AzLocalLog "$DeploymentMode FAILED for $uniqueID (State: $provState)." -Level Error
                }
            } else {
                $allResults += [PSCustomObject]@{
                    TestName  = "$DeploymentMode-$uniqueID"
                    ClassName = "AzLocalDeploymentAutomation.$DeploymentMode"
                    Status    = 'Skipped'
                    Message   = "$DeploymentMode skipped by user (WhatIf/Confirm)."
                    Duration  = 0
                }
            }
        } catch {
            $deployDuration = ((Get-Date) - $deployStartTime).TotalSeconds
            $allResults += [PSCustomObject]@{
                TestName  = "$DeploymentMode-$uniqueID"
                ClassName = "AzLocalDeploymentAutomation.$DeploymentMode"
                Status    = 'Failed'
                Message   = "$DeploymentMode failed for cluster '$uniqueID': $($_.Exception.Message)"
                Duration  = [math]::Round($deployDuration, 2)
            }
            Write-AzLocalLog "$DeploymentMode FAILED for ${uniqueID}: $($_.Exception.Message)" -Level Error
        }
    }

    # Generate JUnit XML report
    if (-not [string]::IsNullOrWhiteSpace($JUnitOutputPath)) {
        New-AzLocalJUnitXml -TestResults $allResults -SuiteName "AzLocalDeployment-$DeploymentMode" -OutputPath $JUnitOutputPath
    }

    # Summary
    $passed = @($allResults | Where-Object { $_.Status -eq 'Passed' }).Count
    $failed = @($allResults | Where-Object { $_.Status -eq 'Failed' }).Count
    $skipped = @($allResults | Where-Object { $_.Status -eq 'Skipped' }).Count

    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp
    Write-AzLocalLog " $DeploymentMode Summary" -Level Info -NoTimestamp
    Write-AzLocalLog " Passed: $passed | Failed: $failed | Skipped: $skipped" -Level Info -NoTimestamp
    Write-AzLocalLog "========================================================" -Level Info -NoTimestamp

    return $allResults
}