Public/Export-AzLocalClusterInventoryDriftReport.ps1

function Export-AzLocalClusterInventoryDriftReport {
    <#
    .SYNOPSIS
        Compares live Azure Local cluster inventory with the source-controlled configuration CSV.
    .DESCRIPTION
        Produces read-only CSV, JSON, JUnit, and markdown reports that identify
        clusters missing from either side and operator-managed tag values that
        differ from config/ClusterUpdateRings.csv. Blank optional tag values are
        treated as unmanaged and preserve the live value, matching the tag
        writer's reconciliation semantics. A missing source CSV is reported as
        an onboarding state and does not fail the command.
    .PARAMETER LiveInventory
        Live rows returned by Invoke-AzLocalClusterInventory.
    .PARAMETER SourceCsvPath
        Path to the source-controlled desired-state CSV.
    .PARAMETER OutputDirectory
        Directory for drift report artifacts.
    .PARAMETER PassThru
        Returns the drift report summary object.
    .OUTPUTS
        Nothing by default. PSCustomObject when PassThru is specified.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [object[]]$LiveInventory,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SourceCsvPath = './config/ClusterUpdateRings.csv',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$OutputDirectory,

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$CsvFileName = 'cluster-inventory-drift.csv',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$JsonFileName = 'cluster-inventory-drift.json',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$XmlFileName = 'cluster-inventory-drift.xml',

        [Parameter(Mandatory = $false)]
        [ValidateNotNullOrEmpty()]
        [string]$SummaryFileName = 'cluster-inventory-drift-summary.md',

        [Parameter(Mandatory = $false)]
        [datetime]$Timestamp = (Get-Date),

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

    $pipelineHost = Get-AzLocalPipelineHost
    if (-not $OutputDirectory) {
        if ($pipelineHost -eq 'AzureDevOps' -and $env:BUILD_ARTIFACTSTAGINGDIRECTORY) {
            $OutputDirectory = $env:BUILD_ARTIFACTSTAGINGDIRECTORY
        }
        else {
            $OutputDirectory = './artifacts'
        }
    }
    if (-not (Test-Path -LiteralPath $OutputDirectory)) {
        New-Item -ItemType Directory -Path $OutputDirectory -Force -WhatIf:$false | Out-Null
    }

    $csvPath = Join-Path -Path $OutputDirectory -ChildPath $CsvFileName
    $jsonPath = Join-Path -Path $OutputDirectory -ChildPath $JsonFileName
    $xmlPath = Join-Path -Path $OutputDirectory -ChildPath $XmlFileName
    $managedFields = @('UpdateRing', 'UpdateStartWindow', 'UpdateExclusionsWindow', 'UpdateExcluded', 'UpdateAuthAccountId')
    $optionalManagedFields = @('UpdateStartWindow', 'UpdateExclusionsWindow', 'UpdateExcluded', 'UpdateAuthAccountId')
    $driftRows = New-Object System.Collections.ArrayList
    $operatorExcludedRows = New-Object System.Collections.ArrayList
    $sourceRows = @()
    $status = 'NotConfigured'

    foreach ($liveRow in @($LiveInventory)) {
        $liveUpdateExcluded = if ($liveRow.PSObject.Properties['UpdateExcluded']) { ([string]$liveRow.UpdateExcluded).Trim() } else { '' }
        if ($liveUpdateExcluded -notmatch '^(?i:true|1)$') { continue }
        [void]$operatorExcludedRows.Add([pscustomobject][ordered]@{
            Status       = 'OperatorExcluded'
            ClusterName  = [string]$liveRow.ClusterName
            ResourceId   = [string]$liveRow.ResourceId
            Field        = 'UpdateExcluded'
            SourceValue  = ''
            LiveValue    = $liveUpdateExcluded
            Detail       = 'Cluster is excluded from update automation. Remove the tag manually, or set UpdateExcluded=False in the source CSV, commit it, and run Config: 2 - Manage UpdateRing Tags.'
        })
    }

    if (Test-Path -LiteralPath $SourceCsvPath -PathType Leaf) {
        $sourceRows = @(Import-Csv -LiteralPath $SourceCsvPath -ErrorAction Stop)
        $sourceColumns = if ($sourceRows.Count -gt 0) {
            @($sourceRows[0].PSObject.Properties.Name)
        }
        else {
            $headerLine = Get-Content -LiteralPath $SourceCsvPath -TotalCount 1
            @($headerLine -split ',' | ForEach-Object { $_.Trim().Trim('"') })
        }
        foreach ($requiredColumn in @('ResourceId', 'ClusterName', 'UpdateRing')) {
            if ($requiredColumn -notin $sourceColumns) {
                throw "Source inventory CSV '$SourceCsvPath' is missing required column '$requiredColumn'."
            }
        }

        $sourceById = @{}
        foreach ($sourceRow in $sourceRows) {
            $resourceId = ([string]$sourceRow.ResourceId).Trim()
            if (-not $resourceId) {
                throw "Source inventory CSV '$SourceCsvPath' contains a row with an empty ResourceId."
            }
            $key = $resourceId.ToLowerInvariant()
            if ($sourceById.ContainsKey($key)) {
                throw "Source inventory CSV '$SourceCsvPath' contains duplicate ResourceId '$resourceId'."
            }
            $sourceById[$key] = $sourceRow
        }

        $liveById = @{}
        foreach ($liveRow in @($LiveInventory)) {
            if (-not $liveRow.PSObject.Properties['ResourceId'] -or -not $liveRow.ResourceId) {
                throw 'Live inventory contains a row with an empty ResourceId.'
            }
            $resourceId = ([string]$liveRow.ResourceId).Trim()
            $key = $resourceId.ToLowerInvariant()
            if ($liveById.ContainsKey($key)) {
                throw "Live inventory contains duplicate ResourceId '$resourceId'."
            }
            $liveById[$key] = $liveRow
        }

        foreach ($key in @($liveById.Keys | Sort-Object)) {
            $liveRow = $liveById[$key]
            if (-not $sourceById.ContainsKey($key)) {
                [void]$driftRows.Add([pscustomobject][ordered]@{
                    Status       = 'LiveOnly'
                    ClusterName  = [string]$liveRow.ClusterName
                    ResourceId   = [string]$liveRow.ResourceId
                    Field        = ''
                    SourceValue  = ''
                    LiveValue    = ''
                    Detail       = 'Cluster exists in Azure but is missing from the source-controlled CSV.'
                })
                continue
            }

            $sourceRow = $sourceById[$key]
            foreach ($field in $managedFields) {
                if ($field -notin $sourceColumns) { continue }
                $sourceValue = if ($sourceRow.PSObject.Properties[$field]) { ([string]$sourceRow.$field).Trim() } else { '' }
                if ($field -in $optionalManagedFields -and -not $sourceValue) { continue }
                $liveValue = if ($liveRow.PSObject.Properties[$field]) { ([string]$liveRow.$field).Trim() } else { '' }
                if (-not [string]::Equals($sourceValue, $liveValue, [StringComparison]::Ordinal)) {
                    [void]$driftRows.Add([pscustomobject][ordered]@{
                        Status       = 'TagMismatch'
                        ClusterName  = [string]$liveRow.ClusterName
                        ResourceId   = [string]$liveRow.ResourceId
                        Field        = $field
                        SourceValue  = $sourceValue
                        LiveValue    = $liveValue
                        Detail       = "Source value '$sourceValue' differs from live value '$liveValue'."
                    })
                }
            }
        }

        foreach ($key in @($sourceById.Keys | Sort-Object)) {
            if ($liveById.ContainsKey($key)) { continue }
            $sourceRow = $sourceById[$key]
            [void]$driftRows.Add([pscustomobject][ordered]@{
                Status       = 'SourceOnly'
                ClusterName  = [string]$sourceRow.ClusterName
                ResourceId   = [string]$sourceRow.ResourceId
                Field        = ''
                SourceValue  = ''
                LiveValue    = ''
                Detail       = 'Cluster exists in the source-controlled CSV but is not visible in the live inventory.'
            })
        }
        $status = if ($driftRows.Count -gt 0) { 'Drift' } elseif ($operatorExcludedRows.Count -gt 0) { 'Review' } else { 'Clean' }
    }

    $driftResultRows = @($driftRows.ToArray() | Sort-Object Status, ClusterName, Field)
    $operatorExcludedResultRows = @($operatorExcludedRows.ToArray() | Sort-Object ClusterName)
    $rows = @($driftResultRows) + @($operatorExcludedResultRows)
    $liveOnlyRows = @($driftResultRows | Where-Object { $_.Status -eq 'LiveOnly' })
    $sourceOnlyRows = @($driftResultRows | Where-Object { $_.Status -eq 'SourceOnly' })
    $tagMismatchRows = @($driftResultRows | Where-Object { $_.Status -eq 'TagMismatch' })
    $tagMismatchClusters = @($tagMismatchRows | Select-Object -ExpandProperty ResourceId -Unique)
    $matchingCount = if ($status -eq 'NotConfigured') { 0 } else {
        @($LiveInventory).Count - $liveOnlyRows.Count - $tagMismatchClusters.Count
    }

    if ($rows.Count -gt 0) {
        $rows | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Encoding UTF8 -WhatIf:$false
    }
    else {
        Set-Content -LiteralPath $csvPath -Value 'Status,ClusterName,ResourceId,Field,SourceValue,LiveValue,Detail' -Encoding UTF8 -WhatIf:$false
    }

    $report = [pscustomobject][ordered]@{
        Status                  = $status
        SourceCsvPath           = $SourceCsvPath
        LiveClusterCount        = [int]@($LiveInventory).Count
        SourceClusterCount      = [int]$sourceRows.Count
        MatchingClusterCount    = [int]$matchingCount
        LiveOnlyCount           = [int]$liveOnlyRows.Count
        SourceOnlyCount         = [int]$sourceOnlyRows.Count
        TagMismatchClusterCount = [int]$tagMismatchClusters.Count
        FieldDiscrepancyCount   = [int]$tagMismatchRows.Count
        OperatorExcludedCount   = [int]$operatorExcludedResultRows.Count
        DriftCount              = [int]$driftResultRows.Count
        FindingCount            = [int]$rows.Count
        CsvPath                 = $csvPath
        JsonPath                = $jsonPath
        XmlPath                 = $xmlPath
        Rows                    = $rows
    }
    $report | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $jsonPath -Encoding UTF8 -WhatIf:$false

    $membershipCases = @()
    $tagCases = @()
    if ($status -eq 'NotConfigured') {
        $membershipCases = @(@{
            Name = 'Source-controlled cluster inventory is configured'
            Skipped = "Source CSV not found at '$SourceCsvPath'."
        })
        $tagCases = @(@{
            Name = 'Managed cluster tags match source control'
            Skipped = 'Tag comparison requires a source-controlled inventory CSV.'
        })
    }
    else {
        $membershipMessage = "$($liveOnlyRows.Count) live-only cluster(s); $($sourceOnlyRows.Count) source-only cluster(s)."
        $membershipCase = @{ Name = 'Live and source-controlled cluster membership match' }
        if ($liveOnlyRows.Count -gt 0 -or $sourceOnlyRows.Count -gt 0) {
            $membershipCase.Failure = @{ Message = $membershipMessage; Type = 'InventoryMembershipDrift'; Body = $membershipMessage }
        }
        else { $membershipCase.SystemOut = 'No cluster membership drift detected.' }
        $membershipCases = @($membershipCase)

        $tagMessage = "$($tagMismatchClusters.Count) cluster(s) have $($tagMismatchRows.Count) managed-tag discrepancy(s)."
        $tagCase = @{ Name = 'Managed cluster tags match source control' }
        if ($tagMismatchRows.Count -gt 0) {
            $tagCase.Failure = @{ Message = $tagMessage; Type = 'ManagedTagDrift'; Body = $tagMessage }
        }
        else { $tagCase.SystemOut = 'No managed tag drift detected.' }
        $tagCases = @($tagCase)
    }
    $operatorExclusionMessage = "$($operatorExcludedResultRows.Count) cluster(s) have UpdateExcluded=True/1 and will remain outside update automation."
    $operatorExclusionCase = @{ Name = 'No clusters have an active UpdateExcluded operator hold' }
    if ($operatorExcludedResultRows.Count -gt 0) {
        $operatorExclusionCase.Failure = @{ Message = $operatorExclusionMessage; Type = 'OperatorExcluded'; Body = $operatorExclusionMessage }
    }
    else { $operatorExclusionCase.SystemOut = 'No active UpdateExcluded operator holds detected.' }
    [void](New-AzLocalPipelineJUnitXml -TestSuitesName 'Config: 1 - Cluster Inventory Drift' -Suites @(
        @{ Name = 'Inventory Membership'; ClassName = 'ClusterInventoryDrift'; TestCases = $membershipCases }
        @{ Name = 'Managed Tags'; ClassName = 'ClusterInventoryDrift'; TestCases = $tagCases }
        @{ Name = 'Operator Exclusions'; ClassName = 'ClusterInventoryDrift'; TestCases = @($operatorExclusionCase) }
    ) -OutputPath $xmlPath -Timestamp $Timestamp)

    $md = New-Object System.Text.StringBuilder
    [void]$md.AppendLine('## Cluster Inventory Drift')
    [void]$md.AppendLine('')
    if ($status -eq 'NotConfigured') {
        [void]$md.AppendLine(":information_source: **Status: NOT CONFIGURED** - source CSV not found at ``$SourceCsvPath``.")
        [void]$md.AppendLine('')
        [void]$md.AppendLine('Download the generated inventory artifact, assign update-management values, and commit it as `config/ClusterUpdateRings.csv`. Drift comparison will begin on the next run.')
    }
    else {
        $statusIcon = if ($status -eq 'Clean') { ':white_check_mark:' } else { ':warning:' }
        [void]$md.AppendLine("$statusIcon **Status: $($status.ToUpperInvariant())**")
        [void]$md.AppendLine('')
        [void]$md.AppendLine('| Metric | Count |')
        [void]$md.AppendLine('|--------|------:|')
        [void]$md.AppendLine("| Live clusters | $(@($LiveInventory).Count) |")
        [void]$md.AppendLine("| Source-controlled clusters | $($sourceRows.Count) |")
        [void]$md.AppendLine("| Matching clusters | $matchingCount |")
        [void]$md.AppendLine("| New live clusters missing from source control | $($liveOnlyRows.Count) |")
        [void]$md.AppendLine("| Source-controlled clusters missing from live inventory | $($sourceOnlyRows.Count) |")
        [void]$md.AppendLine("| Clusters with managed-tag drift | $($tagMismatchClusters.Count) |")
        [void]$md.AppendLine("| Managed-tag field discrepancies | $($tagMismatchRows.Count) |")
        [void]$md.AppendLine("| Clusters with UpdateExcluded=True/1 | $($operatorExcludedResultRows.Count) |")
        if ($driftResultRows.Count -gt 0) {
            [void]$md.AppendLine('')
            [void]$md.AppendLine('### Discrepancies')
            [void]$md.AppendLine('')
            [void]$md.AppendLine('| Status | Cluster | Field | Source | Live |')
            [void]$md.AppendLine('|--------|---------|-------|--------|------|')
            foreach ($row in @($driftResultRows | Select-Object -First 50)) {
                $clusterCell = ConvertTo-AzLocalMarkdownTableCell -Value ([string]$row.ClusterName)
                $fieldCell = if ($row.Field) { ConvertTo-AzLocalMarkdownTableCell -Value ([string]$row.Field) } else { '-' }
                $sourceCell = if ($row.SourceValue) { ConvertTo-AzLocalMarkdownTableCell -Value ([string]$row.SourceValue) } else { '-' }
                $liveCell = if ($row.LiveValue) { ConvertTo-AzLocalMarkdownTableCell -Value ([string]$row.LiveValue) } else { '-' }
                [void]$md.AppendLine("| $($row.Status) | $clusterCell | $fieldCell | $sourceCell | $liveCell |")
            }
            if ($driftResultRows.Count -gt 50) {
                [void]$md.AppendLine('')
                [void]$md.AppendLine("_Showing 50 of $($driftResultRows.Count) discrepancies. Download ``$CsvFileName`` for the full report._")
            }
        }
    }
    if ($operatorExcludedResultRows.Count -gt 0) {
        [void]$md.AppendLine('')
        [void]$md.AppendLine('### Active UpdateExcluded operator holds')
        [void]$md.AppendLine('')
        [void]$md.AppendLine('These clusters are excluded from update automation until the operator hold is cleared:')
        [void]$md.AppendLine('')
        [void]$md.AppendLine('| Cluster | Live UpdateExcluded |')
        [void]$md.AppendLine('|---------|--------------------|')
        foreach ($row in $operatorExcludedResultRows) {
            $clusterCell = ConvertTo-AzLocalMarkdownTableCell -Value ([string]$row.ClusterName)
            $liveCell = ConvertTo-AzLocalMarkdownTableCell -Value ([string]$row.LiveValue)
            [void]$md.AppendLine("| $clusterCell | $liveCell |")
        }
        [void]$md.AppendLine('')
        [void]$md.AppendLine('Remove the tag manually, or set `UpdateExcluded=False` for these clusters in `config/ClusterUpdateRings.csv`, commit the change, and run **Config: 2 - Manage UpdateRing Tags**.')
    }
    Add-AzLocalPipelineStepSummary -Markdown $md.ToString() -SummaryFileName $SummaryFileName | Out-Null

    Set-AzLocalPipelineOutput -Name 'inventory_drift_status' -Value $status
    Set-AzLocalPipelineOutput -Name 'inventory_drift_count' -Value ([string]$driftResultRows.Count)
    Set-AzLocalPipelineOutput -Name 'inventory_live_only_count' -Value ([string]$liveOnlyRows.Count)
    Set-AzLocalPipelineOutput -Name 'inventory_source_only_count' -Value ([string]$sourceOnlyRows.Count)
    Set-AzLocalPipelineOutput -Name 'inventory_tag_mismatch_count' -Value ([string]$tagMismatchClusters.Count)
    Set-AzLocalPipelineOutput -Name 'inventory_operator_excluded_count' -Value ([string]$operatorExcludedResultRows.Count)

    if ($status -eq 'Drift') {
        Write-AzLocalPipelineWarning -Title 'Cluster inventory drift detected' -Message "$($driftResultRows.Count) discrepancy row(s) found. Review the pipeline summary and '$CsvFileName' artifact."
    }
    if ($operatorExcludedResultRows.Count -gt 0) {
        $operatorExcludedNames = @($operatorExcludedResultRows | Select-Object -ExpandProperty ClusterName)
        Write-AzLocalPipelineWarning -Title 'Clusters excluded from update automation' -Message "$($operatorExcludedResultRows.Count) cluster(s) have UpdateExcluded=True/1: $($operatorExcludedNames -join ', '). Remove the tag manually, or set UpdateExcluded=False in the source CSV, commit it, and run Config: 2 - Manage UpdateRing Tags."
    }

    if ($PassThru) { return $report }
}