Public/Compare-IntuneAssignmentSnapshot.ps1
|
function Compare-IntuneAssignmentSnapshot { <# .SYNOPSIS Compares two Intune assignment snapshot files. .DESCRIPTION Reports Added, Removed, and Changed canonical assignment records using a schema-versioned stable identity key. By default, comparison rejects failed or unknown coverage, cross-tenant, or category-coverage-mismatched snapshots. Categories marked Skipped are excluded from both sides with a warning. .PARAMETER ReferencePath Older or baseline snapshot. .PARAMETER DifferencePath Newer snapshot. .PARAMETER AllowIncompleteCoverage Allows comparison when either snapshot contains failed or unknown coverage. .PARAMETER AllowCoverageMismatch Allows comparison when the snapshots cover different category ID sets. .EXAMPLE Compare-IntuneAssignmentSnapshot -ReferencePath './before.json' -DifferencePath './after.json' #> [CmdletBinding()] [OutputType('IntuneAssignmentChecker.AssignmentSnapshotDifference')] param( [Parameter(Mandatory)] [string]$ReferencePath, [Parameter(Mandatory)] [string]$DifferencePath, [Parameter()] [switch]$AllowIncompleteCoverage, [Parameter()] [switch]$AllowCoverageMismatch ) $reference = Read-IACAssignmentSnapshot -Path $ReferencePath $difference = Read-IACAssignmentSnapshot -Path $DifferencePath if ("$($reference.Tenant.Id)" -cne "$($difference.Tenant.Id)") { throw "Snapshots belong to different tenants ('$($reference.Tenant.Id)' and '$($difference.Tenant.Id)')." } $hasBlockingIncompleteCoverage = $false foreach ($snapshot in @($reference, $difference)) { if ([bool]$snapshot.Coverage.Complete) { continue } $snapshotSkippedCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) $hasBlockingStatus = $false foreach ($category in @($snapshot.Coverage.Categories)) { if ($category.Status -ceq 'Skipped') { [void]$snapshotSkippedCategories.Add("$($category.CategoryId)") } elseif ($category.Status -in @('Failed', 'Unknown')) { $hasBlockingStatus = $true } } $hasNonSkippedError = $false foreach ($coverageError in @($snapshot.Coverage.Errors)) { if (-not $snapshotSkippedCategories.Contains("$($coverageError.CategoryId)")) { $hasNonSkippedError = $true break } } if ($hasBlockingStatus -or $hasNonSkippedError -or $snapshotSkippedCategories.Count -eq 0) { $hasBlockingIncompleteCoverage = $true break } } if (-not $AllowIncompleteCoverage -and $hasBlockingIncompleteCoverage) { throw 'One or both snapshots have incomplete category coverage; use -AllowIncompleteCoverage to compare them explicitly.' } $referenceCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($categoryId in @($reference.Coverage.Categories.CategoryId)) { [void]$referenceCategories.Add("$categoryId") } $differenceCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($categoryId in @($difference.Coverage.Categories.CategoryId)) { [void]$differenceCategories.Add("$categoryId") } if (-not $AllowCoverageMismatch -and -not $referenceCategories.SetEquals($differenceCategories)) { throw 'Snapshots cover different category sets; use -AllowCoverageMismatch to compare them explicitly.' } $skippedCategories = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) foreach ($category in @($reference.Coverage.Categories) + @($difference.Coverage.Categories)) { if ($category.Status -ceq 'Skipped') { [void]$skippedCategories.Add("$($category.CategoryId)") } } if ($skippedCategories.Count -gt 0) { $orderedSkippedCategories = [System.Collections.Generic.List[string]]::new($skippedCategories) $orderedSkippedCategories.Sort([System.StringComparer]::Ordinal) Write-Warning "Skipped categories were not compared: $($orderedSkippedCategories -join ', ')." } $referenceByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($record in @($reference.Records)) { if ($skippedCategories.Contains("$($record.CategoryId)")) { continue } $referenceByKey.Add((Get-IACAssignmentIdentityKey -Record $record), $record) } $differenceByKey = [System.Collections.Generic.SortedDictionary[string, object]]::new([System.StringComparer]::Ordinal) foreach ($record in @($difference.Records)) { if ($skippedCategories.Contains("$($record.CategoryId)")) { continue } $differenceByKey.Add((Get-IACAssignmentIdentityKey -Record $record), $record) } $changes = [System.Collections.Generic.List[object]]::new() $propertyNames = Get-IACAssignmentRecordPropertyNames foreach ($key in $differenceByKey.Keys) { if (-not $referenceByKey.ContainsKey($key)) { $after = $differenceByKey[$key] [void]$changes.Add([PSCustomObject][ordered]@{ ChangeType = 'Added' IdentityKey = $key CategoryId = $after.CategoryId PolicyId = $after.PolicyId AssignmentId = $after.AssignmentId ChangedFields = @() Before = $null After = $after }) continue } $before = $referenceByKey[$key] $after = $differenceByKey[$key] $changedFields = foreach ($propertyName in $propertyNames) { $beforeJson = ConvertTo-Json -InputObject $before.$propertyName -Depth 12 -Compress $afterJson = ConvertTo-Json -InputObject $after.$propertyName -Depth 12 -Compress if (-not [string]::Equals($beforeJson, $afterJson, [System.StringComparison]::Ordinal)) { $propertyName } } if (@($changedFields).Count -gt 0) { [void]$changes.Add([PSCustomObject][ordered]@{ ChangeType = 'Changed' IdentityKey = $key CategoryId = $after.CategoryId PolicyId = $after.PolicyId AssignmentId = $after.AssignmentId ChangedFields = @($changedFields) Before = $before After = $after }) } } foreach ($key in $referenceByKey.Keys) { if ($differenceByKey.ContainsKey($key)) { continue } $before = $referenceByKey[$key] [void]$changes.Add([PSCustomObject][ordered]@{ ChangeType = 'Removed' IdentityKey = $key CategoryId = $before.CategoryId PolicyId = $before.PolicyId AssignmentId = $before.AssignmentId ChangedFields = @() Before = $before After = $null }) } foreach ($changeType in @('Added', 'Removed', 'Changed')) { foreach ($change in @($changes | Where-Object ChangeType -CEQ $changeType)) { $change.PSObject.TypeNames.Insert(0, 'IntuneAssignmentChecker.AssignmentSnapshotDifference') Write-Output $change } } } |