Public/Compare-UTCMConfiguration.ps1

function Compare-UTCMConfiguration {
    <#
    .SYNOPSIS
        Compares two UTCM snapshots or a baseline snapshot against the current tenant state.
 
    .DESCRIPTION
        Downloads configuration items from both snapshots, normalizes them via
        ConvertTo-NormalizedJson, and runs Compare-Object to produce a diff.
 
        Two parameter sets:
          ByTwoSnapshots — compare BaselineSnapshotId vs CompareSnapshotId.
          AgainstCurrent — use the baseline's original requested resources, including empty types, create a temp current-state
                             snapshot, and compare.
        Incomplete snapshots are rejected by default. A known-partial baseline is rejected
        before a current-state job is created. The explicit partial override is for investigation,
        not reliable unattended drift conclusions.
 
    .PARAMETER BaselineSnapshotId
        GUID of the baseline snapshot (mandatory in both parameter sets).
 
    .PARAMETER CompareSnapshotId
        GUID of the second snapshot to compare against (ByTwoSnapshots set only).
 
    .PARAMETER PollingIntervalSeconds
        Seconds between polls when creating a current-state snapshot. Default: 10.
 
    .PARAMETER AllowPartialComparison
        Explicitly allow partiallySuccessful snapshots, with prominent warnings.
        Missing data and an empty diff cannot establish reliable drift or absence of drift.
 
    .OUTPUTS
        Array of diff objects with id, displayName, type, normalizedData, and SideIndicator.
        '=>' = added/changed in current. '<=' = missing/changed from baseline.
 
    .EXAMPLE
        Compare-UTCMConfiguration -BaselineSnapshotId $baseId -CompareSnapshotId $compId
 
    .EXAMPLE
        # Compare baseline to live tenant
        Compare-UTCMConfiguration -BaselineSnapshotId $baseId
    #>

    [CmdletBinding(DefaultParameterSetName='AgainstCurrent')]
    param(
        # Baseline is mandatory for both parameter sets
        [Parameter(Mandatory, ParameterSetName='ByTwoSnapshots')]
        [Parameter(Mandatory, ParameterSetName='AgainstCurrent')]
        [ValidateScript({
            if (-not (Validate-Guid $_)) { throw "BaselineSnapshotId '$_' is not a valid GUID." }
            $true
        })]
        [string]$BaselineSnapshotId,

        # Only required when comparing two static snapshots
        [Parameter(Mandatory, ParameterSetName='ByTwoSnapshots')]
        [ValidateScript({
            if (-not (Validate-Guid $_)) { throw "CompareSnapshotId '$_' is not a valid GUID." }
            $true
        })]
        [string]$CompareSnapshotId,

        # Only used for AgainstCurrent
        [Parameter(ParameterSetName='AgainstCurrent')]
        [ValidateRange(5,300)]
        [int]$PollingIntervalSeconds = 10,

        [switch]$AllowPartialComparison
    )

    if ($PSCmdlet.ParameterSetName -eq 'AgainstCurrent') {
        Ensure-GraphConnection
    } else {
        Ensure-GraphConnection -ReadOnly
    }

    $baseline = Get-UTCMSnapshot -SnapshotId $BaselineSnapshotId -IncludeDetails
    Assert-UTCMComparableSnapshot -Snapshot $baseline -Role 'Baseline' -AllowPartialComparison:$AllowPartialComparison

    if ($PSCmdlet.ParameterSetName -eq 'AgainstCurrent') {
        $baselineResources = @(Get-UTCMComparisonResource -Snapshot $baseline)
    } else {
        $compare = Get-UTCMSnapshot -SnapshotId $CompareSnapshotId -IncludeDetails
        Assert-UTCMComparableSnapshot -Snapshot $compare -Role 'Comparison' -AllowPartialComparison:$AllowPartialComparison
    }

    $baselineDetail = Get-UTCMSnapshot -SnapshotId $BaselineSnapshotId -IncludeItems
    Assert-UTCMComparableSnapshot -Snapshot $baselineDetail -Role 'Baseline' -AllowPartialComparison:$AllowPartialComparison

    # Fetch comparison snapshot (either current-state temp or another fixed snapshot)
    if ($PSCmdlet.ParameterSetName -eq 'AgainstCurrent') {
        $compare = Get-UTCMCurrentStateSnapshot -Resources $baselineResources -PollingIntervalSeconds $PollingIntervalSeconds `
            -AllowPartialComparison:$AllowPartialComparison
        Assert-UTCMComparableSnapshot -Snapshot $compare -Role 'Comparison' -AllowPartialComparison:$AllowPartialComparison
        $compareDetail = $compare
    } else {
        $compareDetail = Get-UTCMSnapshot -SnapshotId $CompareSnapshotId -IncludeItems
        Assert-UTCMComparableSnapshot -Snapshot $compareDetail -Role 'Comparison' -AllowPartialComparison:$AllowPartialComparison
    }

    foreach ($snapshot in @($baselineDetail, $compareDetail)) {
        $diagnostic = Get-UTCMSnapshotDiagnostic -Snapshot $snapshot
        if ($diagnostic.status -eq 'partiallySuccessful') {
            Write-Warning (("INCOMPLETE COMPARISON: -AllowPartialComparison includes snapshot '{0}' with status " +
                "'partiallySuccessful'. Missing/added rows may reflect extraction failures; an empty diff does not " +
                "prove absence of drift. Do not use these results as unattended drift conclusions. {1}") -f
                $diagnostic.id, $diagnostic.errorSummary)
        }
    }

    # Normalize to stable comparison set (avoid slow JSON roundtrips on entire object)
    $baselineItems = @($baselineDetail.configurationItems | Where-Object { $null -ne $_ } | ForEach-Object {
        [pscustomobject]@{
            id             = $_.id
            displayName    = $_.displayName
            type           = $_.type
            normalizedData = ConvertTo-NormalizedJson -InputObject $_.data
        }
    })

    $compareItems = @($compareDetail.configurationItems | Where-Object { $null -ne $_ } | ForEach-Object {
        [pscustomobject]@{
            id             = $_.id
            displayName    = $_.displayName
            type           = $_.type
            normalizedData = ConvertTo-NormalizedJson -InputObject $_.data
        }
    })

    # Compare by stable keys + normalized payload
    $diff = Compare-Object -ReferenceObject $baselineItems -DifferenceObject $compareItems `
                           -Property id, displayName, type, normalizedData -PassThru

    return $diff
}