Public/Compare-TenantLensSnapshot.ps1
|
function Compare-TenantLensSnapshot { <# .SYNOPSIS Detects drift between two snapshots of the same tenant. .DESCRIPTION Normalizes both snapshots (volatile fields removed, deterministic ordering), performs a structural diff per area and reports every change as Added / Removed / Changed with the exact property path and a severity heuristic (CA exclusions and role assignments are highlighted, renames rank as Info). With -Report a self-contained drift HTML is written next to the difference snapshot. .EXAMPLE Compare-TenantLensSnapshot -ReferencePath .\out\contoso_2026-06-23_0900 -DifferencePath .\out\contoso_2026-07-23_1430 -Report #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [Alias('Reference')] [string]$ReferencePath, [Parameter(Mandatory, Position = 1)] [Alias('Difference')] [string]$DifferencePath, # Write a self-contained drift HTML report. [switch]$Report, # Target file for the drift report (implies -Report). [string]$OutputPath, # Open the report in the default browser. [switch]$Open ) $reference = Read-TLSnapshot -Path $ReferencePath $difference = Read-TLSnapshot -Path $DifferencePath $configPath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Config') -ChildPath 'volatile-properties.psd1' $config = Import-PowerShellDataFile -Path $configPath $globalVolatile = @($config['GlobalVolatileProperties']) $areaVolatileMap = $config['AreaVolatileProperties'] $excludedAreas = @($config['DriftExcludedAreas']) $referenceAreas = @($reference.PSObject.Properties.Name | Where-Object { $_ -notlike '_*' }) $differenceAreas = @($difference.PSObject.Properties.Name | Where-Object { $_ -notlike '_*' }) $allAreas = @(@($referenceAreas) + @($differenceAreas) | Sort-Object -Unique) $changes = [System.Collections.Generic.List[object]]::new() $notes = [System.Collections.Generic.List[string]]::new() foreach ($area in $allAreas) { if ($excludedAreas -contains $area) { $notes.Add("Area '$area' is excluded from drift detection (point-in-time report data).") continue } $inReference = $referenceAreas -contains $area $inDifference = $differenceAreas -contains $area if (-not ($inReference -and $inDifference)) { $side = $(if ($inReference) { 'reference' } else { 'difference' }) $notes.Add("Area '$area' was only collected in the $side snapshot - not compared.") continue } $volatile = @($globalVolatile) if ($areaVolatileMap -and $areaVolatileMap.ContainsKey($area)) { $volatile = @($globalVolatile) + @($areaVolatileMap[$area]) } $referenceNormalized = ConvertTo-TLNormalizedObject -InputObject $reference.$area -VolatileProperty $volatile $differenceNormalized = ConvertTo-TLNormalizedObject -InputObject $difference.$area -VolatileProperty $volatile foreach ($change in @(Compare-TLValue -Reference $referenceNormalized -Difference $differenceNormalized)) { $changes.Add([pscustomobject]@{ Area = $area ChangeType = $change.ChangeType ObjectName = $change.Label ObjectId = $change.ObjectId Path = $change.Path Before = $change.Before After = $change.After Severity = (Get-TLDriftSeverity -Area $area -ChangeType $change.ChangeType -Path ([string]$change.Path)) }) } } Write-Verbose ("Drift: {0} changes across {1} areas ({2} notes)." -f $changes.Count, $allAreas.Count, $notes.Count) if ($Report -or $OutputPath -or $Open) { function Get-TLSnapshotLabel { param([object]$Snapshot) $label = Split-Path -Path (Split-Path -Path $Snapshot._Path -Parent) -Leaf if ($Snapshot._Manifest -and $Snapshot._Manifest.PSObject.Properties['tenant'] -and $Snapshot._Manifest.tenant -and $Snapshot._Manifest.tenant.PSObject.Properties['label']) { $label = '{0} ({1})' -f $label, $Snapshot._Manifest.tenant.label } return $label } function Get-TLEncodedText { param([object]$Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) } $severityOrder = @{ High = 0; Medium = 1; Low = 2; Info = 3 } $sortedChanges = @($changes | Sort-Object -Property ` @{ Expression = { $_.Area } }, @{ Expression = { $severityOrder[[string]$_.Severity] } }, @{ Expression = { $_.ObjectName } }) $tiles = [System.Text.StringBuilder]::new() foreach ($changeType in @('Added', 'Removed', 'Changed')) { $count = @($sortedChanges | Where-Object { $_.ChangeType -eq $changeType }).Count [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2}</div></div>' -f $changeType.ToLowerInvariant(), $count, $changeType)) } foreach ($severity in @('High', 'Medium', 'Low', 'Info')) { $count = @($sortedChanges | Where-Object { $_.Severity -eq $severity }).Count [void]$tiles.AppendLine(('<div class="tile"><div class="num {0}">{1}</div><div class="lbl">{2} severity</div></div>' -f $severity.ToLowerInvariant(), $count, $severity)) } $changesHtml = [System.Text.StringBuilder]::new() if ($sortedChanges.Count -eq 0) { [void]$changesHtml.AppendLine('<div class="empty">No drift detected - the snapshots are configuration-identical after normalization.</div>') } else { foreach ($areaGroup in ($sortedChanges | Group-Object -Property Area)) { [void]$changesHtml.AppendLine(('<h2 class="area">{0} ({1})</h2>' -f (Get-TLEncodedText -Text $areaGroup.Name), $areaGroup.Count)) foreach ($change in $areaGroup.Group) { $severityClass = ([string]$change.Severity).ToLowerInvariant() $typeClass = ([string]$change.ChangeType).ToLowerInvariant() [void]$changesHtml.AppendLine(('<div class="change sev-{0}">' -f $severityClass)) [void]$changesHtml.Append('<div class="head">') [void]$changesHtml.Append(('<span class="badge {0}">{1}</span>' -f $severityClass, $change.Severity)) [void]$changesHtml.Append(('<span class="badge {0}">{1}</span>' -f $typeClass, $change.ChangeType)) if ($change.ObjectName) { [void]$changesHtml.Append(('<span class="obj">{0}</span>' -f (Get-TLEncodedText -Text $change.ObjectName))) } if ($change.Path) { [void]$changesHtml.Append(('<span class="path">{0}</span>' -f (Get-TLEncodedText -Text $change.Path))) } [void]$changesHtml.AppendLine('</div>') if ($change.ChangeType -eq 'Changed') { [void]$changesHtml.AppendLine(('<div class="values"><span class="before">{0}</span><span class="arrow">→</span><span class="after">{1}</span></div>' -f ` (Get-TLEncodedText -Text $change.Before), (Get-TLEncodedText -Text $change.After))) } [void]$changesHtml.AppendLine('</div>') } } } $notesHtml = '' if ($notes.Count -gt 0) { $noteItems = @($notes | ForEach-Object { '<li>{0}</li>' -f (Get-TLEncodedText -Text $_) }) $notesHtml = '<div class="notes"><strong>Notes</strong><ul>' + ($noteItems -join '') + '</ul></div>' } $templatePath = Join-Path -Path (Join-Path -Path $script:TLModuleRoot -ChildPath 'Templates') -ChildPath 'drift.html' $html = Get-Content -Path $templatePath -Raw $referenceLabel = Get-TLSnapshotLabel -Snapshot $reference $differenceLabel = Get-TLSnapshotLabel -Snapshot $difference $html = $html.Replace('{{TITLE}}', (Get-TLEncodedText -Text ("TenantLens drift - {0} vs {1}" -f $referenceLabel, $differenceLabel))) $html = $html.Replace('{{REF_LABEL}}', (Get-TLEncodedText -Text $referenceLabel)) $html = $html.Replace('{{DIFF_LABEL}}', (Get-TLEncodedText -Text $differenceLabel)) $html = $html.Replace('{{GENERATED}}', ([DateTime]::UtcNow.ToString('yyyy-MM-dd HH:mm') + ' UTC')) $html = $html.Replace('{{TOTAL_CHANGES}}', "$($sortedChanges.Count)") $html = $html.Replace('{{SUMMARY_TILES}}', $tiles.ToString()) $html = $html.Replace('{{CHANGES_HTML}}', $changesHtml.ToString()) $html = $html.Replace('{{NOTES_HTML}}', $notesHtml) $html = $html.Replace('{{TOOL_VERSION}}', (Get-TLEncodedText -Text $script:TLVersion)) if (-not $OutputPath) { $runFolder = $difference._Path if ((Split-Path -Path $runFolder -Leaf) -eq 'snapshot') { $runFolder = Split-Path -Path $runFolder -Parent } $referenceLeaf = Split-Path -Path (Split-Path -Path $reference._Path -Parent) -Leaf $differenceLeaf = Split-Path -Path $runFolder -Leaf $OutputPath = Join-Path -Path $runFolder -ChildPath ("drift_{0}_vs_{1}.html" -f $referenceLeaf, $differenceLeaf) } Write-TLFile -Path $OutputPath -Content $html Write-Verbose ("Drift report written to '{0}'." -f $OutputPath) if ($Open) { Invoke-Item -Path $OutputPath } } return $changes } |