Private/Import-AzLocalDeploymentCsv.ps1

Function Import-AzLocalDeploymentCsv {
    <#
    .SYNOPSIS
 
    Reads and validates a cluster deployment CSV file.
 
    .DESCRIPTION
 
    Imports a CSV file containing cluster deployment definitions and validates that all
    required columns are present and row values are well-formed. Returns an array of
    PSCustomObjects, optionally filtered to rows where ReadyToDeploy is TRUE.
 
    Required CSV columns:
    UniqueID, ReadyToDeploy, SubscriptionId, TenantId, TypeOfDeployment, NodeCount,
    CredentialKeyVaultName, SubnetMask, DefaultGateway, StartingIPAddress, EndingIPAddress,
    NodeIPAddresses
 
    Optional columns (fall back to naming-standards-config.json defaults):
    Location, DnsServers, LocalAdminSecretName, LCMAdminSecretName
 
    .PARAMETER CsvFilePath
    Path to the CSV file.
 
    .PARAMETER ReadyOnly
    If specified, returns only rows where ReadyToDeploy is TRUE.
 
    #>


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

        [Parameter(Mandatory = $false)]
        [switch]$ReadyOnly
    )

    if (-not (Test-Path $CsvFilePath)) {
        Write-AzLocalLog "CSV file not found: '$CsvFilePath'." -Level Error
        throw "CSV file not found: '$CsvFilePath'."
    }

    try {
        $csvData = @(Import-Csv -Path $CsvFilePath -ErrorAction Stop)
    } catch {
        Write-AzLocalLog "Failed to parse CSV file '$CsvFilePath'." -Level Error
        throw "Failed to parse CSV file '$CsvFilePath'. $($_.Exception.Message)"
    }

    if ($csvData.Count -eq 0) {
        Write-AzLocalLog "CSV file '$CsvFilePath' contains no data rows." -Level Error
        throw "CSV file '$CsvFilePath' contains no data rows."
    }

    # Validate required columns
    $requiredColumns = @(
        'UniqueID', 'ReadyToDeploy', 'SubscriptionId', 'TenantId',
        'TypeOfDeployment', 'NodeCount', 'CredentialKeyVaultName',
        'SubnetMask', 'DefaultGateway', 'StartingIPAddress', 'EndingIPAddress',
        'NodeIPAddresses'
    )
    $presentColumns = $csvData[0].PSObject.Properties.Name
    $missingColumns = @($requiredColumns | Where-Object { $_ -notin $presentColumns })
    if ($missingColumns.Count -gt 0) {
        $missingList = $missingColumns -join ', '
        Write-AzLocalLog "CSV file is missing required columns: $missingList" -Level Error
        throw "CSV file is missing required columns: $missingList"
    }

    # Validate each row
    $errors = @()
    $validTypes = @('SingleNode', 'StorageSwitchless', 'StorageSwitched', 'RackAware')
    $rowNum = 1
    foreach ($row in $csvData) {
        $rowNum++
        $uid = $row.UniqueID

        # UniqueID validation
        if ([string]::IsNullOrWhiteSpace($uid)) {
            $errors += "Row ${rowNum}: UniqueID is empty."
        } elseif ($uid -notmatch '^[a-zA-Z0-9]{2,8}$') {
            $errors += "Row $rowNum (UniqueID=$uid): UniqueID must be 2-8 alphanumeric characters."
        }

        # ReadyToDeploy validation (PowerShell -notin is case-insensitive)
        if ($row.ReadyToDeploy -notin @('TRUE', 'FALSE')) {
            $errors += "Row $rowNum (UniqueID=$uid): ReadyToDeploy must be TRUE or FALSE."
        }

        # GUID validation
        $guidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
        if ($row.SubscriptionId -notmatch $guidPattern) {
            $errors += "Row $rowNum (UniqueID=$uid): SubscriptionId is not a valid GUID."
        }
        if ($row.TenantId -notmatch $guidPattern) {
            $errors += "Row $rowNum (UniqueID=$uid): TenantId is not a valid GUID."
        }

        # TypeOfDeployment validation
        if ($row.TypeOfDeployment -notin $validTypes) {
            $errors += "Row $rowNum (UniqueID=$uid): TypeOfDeployment must be one of: $($validTypes -join ', ')."
        }

        # NodeCount validation
        $nodeCount = 0
        if (-not [int]::TryParse($row.NodeCount, [ref]$nodeCount)) {
            $errors += "Row $rowNum (UniqueID=$uid): NodeCount must be an integer."
        }

        # Cross-validate NodeCount against TypeOfDeployment constraints
        if ($nodeCount -gt 0 -and $row.TypeOfDeployment -in $validTypes) {
            switch ($row.TypeOfDeployment) {
                'SingleNode'        { if ($nodeCount -ne 1) { $errors += "Row $rowNum (UniqueID=$uid): SingleNode requires NodeCount = 1, got $nodeCount." } }
                'StorageSwitchless' { if ($nodeCount -lt 2 -or $nodeCount -gt 4) { $errors += "Row $rowNum (UniqueID=$uid): StorageSwitchless requires NodeCount 2-4, got $nodeCount." } }
                'StorageSwitched'   { if ($nodeCount -lt 2 -or $nodeCount -gt 16) { $errors += "Row $rowNum (UniqueID=$uid): StorageSwitched requires NodeCount 2-16, got $nodeCount." } }
                'RackAware'         { if ($nodeCount -notin @(2, 4, 6, 8)) { $errors += "Row $rowNum (UniqueID=$uid): RackAware requires NodeCount 2, 4, 6, or 8, got $nodeCount." } }
            }
        }

        # IP address fields validation
        foreach ($ipField in @('SubnetMask', 'DefaultGateway', 'StartingIPAddress', 'EndingIPAddress')) {
            $ipVal = $row.$ipField
            if (-not [string]::IsNullOrWhiteSpace($ipVal)) {
                try { [System.Net.IPAddress]::Parse($ipVal) | Out-Null } catch {
                    $errors += "Row $rowNum (UniqueID=$uid): $ipField '$ipVal' is not a valid IP address."
                }
            }
        }

        # NodeIPAddresses validation (semicolon-separated)
        if (-not [string]::IsNullOrWhiteSpace($row.NodeIPAddresses)) {
            $nodeIPs = $row.NodeIPAddresses -split ';'
            $validNodeIPs = @()
            foreach ($nip in $nodeIPs) {
                $nip = $nip.Trim()
                if ($nip -ne '') {
                    try { [System.Net.IPAddress]::Parse($nip) | Out-Null; $validNodeIPs += $nip } catch {
                        $errors += "Row $rowNum (UniqueID=$uid): NodeIPAddress '$nip' is not a valid IP address."
                    }
                }
            }

            # Validate NodeIPAddresses count matches NodeCount
            $expectedCount = if ($row.TypeOfDeployment -eq 'SingleNode') { 1 } else { $nodeCount }
            if ($nodeCount -gt 0 -and $validNodeIPs.Count -ne $expectedCount) {
                $errors += "Row $rowNum (UniqueID=$uid): NodeIPAddresses count ($($validNodeIPs.Count)) does not match expected node count ($expectedCount)."
            }
        }
    }

    if ($errors.Count -gt 0) {
        $errorMessage = "CSV validation failed with $($errors.Count) error(s):`n" + ($errors -join "`n")
        Write-AzLocalLog $errorMessage -Level Error
        throw $errorMessage
    }

    Write-AzLocalLog "CSV file validated successfully: $($csvData.Count) row(s) found." -Level Success

    if ($ReadyOnly) {
        $csvData = @($csvData | Where-Object { $_.ReadyToDeploy -eq 'TRUE' })
        Write-AzLocalLog "Filtered to $($csvData.Count) row(s) with ReadyToDeploy = TRUE." -Level Info
    }

    return $csvData
}