Public/Get-VMDrift.ps1
|
function Get-VMDrift { <# .SYNOPSIS Detects configuration drift by comparing current VM state against the last sync. .DESCRIPTION After Sync-VMTags runs, it saves a state file containing each VM's metadata at sync time. Get-VMDrift loads that state file, connects to vCenter, collects current metadata, and compares the two snapshots to identify drift. Three types of drift are detected: - Added: VMs that exist now but were not present in the last sync. - Removed: VMs that were in the last sync but no longer exist. - Changed: VMs whose key properties have changed since the last sync (power state, guest OS, CPU, memory, tools status, snapshots, networks, datastores, cluster, datacenter). Results can be exported to an HTML drift report with change details. .PARAMETER Server The vCenter Server, ESXi host, or Hyper-V hostname/IP to connect to. .PARAMETER Credential PSCredential for authentication. .PARAMETER StateFilePath Path to a specific state file to compare against. If not specified, uses the most recent sync state from the module state directory. .PARAMETER OutputPath Path to save an HTML drift report. .EXAMPLE Get-VMDrift -Server vcenter.contoso.com Compares current state against the last Sync-VMTags run. .EXAMPLE Get-VMDrift -Server vcenter.contoso.com -OutputPath .\drift-report.html -Verbose Generates a drift report with verbose output. .EXAMPLE $drift = Get-VMDrift -Server vcenter.contoso.com $drift.Changed | Where-Object { $_.Changes -match 'PowerState' } Finds VMs whose power state changed since the last sync. .NOTES Author: Larry Roberts Requires: VMware.PowerCLI module (for VMware) or Hyper-V module (for HyperV) Part of the VM-AutoTagger module. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Server, [Parameter()] [PSCredential]$Credential, [Parameter()] [string]$StateFilePath, [Parameter()] [string]$OutputPath, [Parameter()] [ValidateSet('VMware', 'HyperV')] [string]$Hypervisor = 'VMware' ) begin { $startTime = Get-Date Write-Verbose "VM Drift detection starting at $startTime" } process { # --- Step 1: Load previous sync state --- if (-not $StateFilePath) { $StateFilePath = Join-Path -Path $script:StateDir -ChildPath 'last-sync.json' } if (-not (Test-Path $StateFilePath)) { Write-Error "No sync state file found at '$StateFilePath'. Run Sync-VMTags first to establish a baseline." return } Write-Verbose "Loading previous state from: $StateFilePath" $previousState = $null try { $rawJson = Get-Content -Path $StateFilePath -Raw -ErrorAction Stop $previousState = $rawJson | ConvertFrom-Json -ErrorAction Stop } catch { Write-Error "Failed to load sync state file: $_" return } if (-not $previousState.VMs -or $previousState.VMs.Count -eq 0) { Write-Warning "Previous sync state contains no VMs. Cannot detect drift." return } $previousSyncDate = $previousState.SyncDate $previousVMs = @{} foreach ($pvm in $previousState.VMs) { $previousVMs[$pvm.Name] = $pvm } Write-Verbose "Previous state: $($previousVMs.Count) VMs from $previousSyncDate" # --- Step 2: Connect and get current state --- $viConnection = $null try { $viConnection = Connect-Hypervisor -Server $Server -Credential $Credential -Hypervisor $Hypervisor Write-Verbose "Connected to $Server" } catch { Write-Error "Failed to connect to $Hypervisor host '$Server': $_" return } $added = [System.Collections.ArrayList]::new() $removed = [System.Collections.ArrayList]::new() $changed = [System.Collections.ArrayList]::new() try { Write-Verbose "Retrieving current VMs..." if ($Hypervisor -eq 'HyperV') { $getVMParams = @{ ErrorAction = 'Stop' } if ($Server -ne 'localhost' -and $Server -ne $env:COMPUTERNAME -and $Server -ne '.') { $getVMParams['ComputerName'] = $Server } $currentVMs = @(Hyper-V\Get-VM @getVMParams) } else { $currentVMs = @(Get-VM -Server $Server -ErrorAction Stop) } Write-Verbose "Current VM count: $($currentVMs.Count)" $currentVMNames = @{} $vmIndex = 0 foreach ($vm in $currentVMs) { $vmIndex++ Write-Progress -Activity "Detecting VM Drift" -Status "$($vm.Name) ($vmIndex of $($currentVMs.Count))" -PercentComplete ([math]::Round(($vmIndex / $currentVMs.Count) * 100)) $currentVMNames[$vm.Name] = $true $metadata = $null try { if ($Hypervisor -eq 'HyperV') { $metadata = Get-HyperVMetadata -VM $vm } else { $metadata = Get-VMMetadata -VM $vm } } catch { Write-Warning "Could not get metadata for $($vm.Name): $_" continue } if (-not $previousVMs.ContainsKey($vm.Name)) { # New VM -- not in previous sync [void]$added.Add([PSCustomObject]@{ VMName = $vm.Name PowerState = $metadata.PowerState CPU = $metadata.NumCPU MemoryGB = $metadata.MemoryGB Cluster = $metadata.Cluster Datacenter = $metadata.Datacenter }) continue } # Compare with previous state $prev = $previousVMs[$vm.Name] $changes = [System.Collections.ArrayList]::new() # Compare key properties if ([string]$prev.PowerState -ne [string]$metadata.PowerState) { [void]$changes.Add(('PowerState: ' + [string]$prev.PowerState + ' -> ' + [string]$metadata.PowerState)) } if ([string]$prev.GuestFamily -ne [string]$metadata.GuestFamily) { [void]$changes.Add(('GuestFamily: ' + [string]$prev.GuestFamily + ' -> ' + [string]$metadata.GuestFamily)) } if ([string]$prev.GuestFullName -ne [string]$metadata.GuestFullName) { [void]$changes.Add(('GuestOS: ' + [string]$prev.GuestFullName + ' -> ' + [string]$metadata.GuestFullName)) } if ([int]$prev.NumCPU -ne [int]$metadata.NumCPU) { [void]$changes.Add(('CPU: ' + [string]$prev.NumCPU + ' -> ' + [string]$metadata.NumCPU)) } if ([double]$prev.MemoryGB -ne [double]$metadata.MemoryGB) { [void]$changes.Add(('Memory: ' + [string]$prev.MemoryGB + 'GB -> ' + [string]$metadata.MemoryGB + 'GB')) } if ([string]$prev.ToolsStatus -ne [string]$metadata.ToolsStatus) { [void]$changes.Add(('ToolsStatus: ' + [string]$prev.ToolsStatus + ' -> ' + [string]$metadata.ToolsStatus)) } if ([int]$prev.SnapshotCount -ne [int]$metadata.SnapshotCount) { [void]$changes.Add(('Snapshots: ' + [string]$prev.SnapshotCount + ' -> ' + [string]$metadata.SnapshotCount)) } if ([string]$prev.SnapshotRisk -ne [string]$metadata.SnapshotRisk) { [void]$changes.Add(('SnapshotRisk: ' + [string]$prev.SnapshotRisk + ' -> ' + [string]$metadata.SnapshotRisk)) } # Compare networks (order-independent) $prevNets = @() if ($prev.Networks) { $prevNets = @($prev.Networks | Sort-Object) } $currNets = @() if ($metadata.Networks) { $currNets = @($metadata.Networks | Sort-Object) } $prevNetStr = $prevNets -join ',' $currNetStr = $currNets -join ',' if ($prevNetStr -ne $currNetStr) { [void]$changes.Add(('Networks: [' + $prevNetStr + '] -> [' + $currNetStr + ']')) } # Compare datastores (order-independent) $prevDS = @() if ($prev.Datastores) { $prevDS = @($prev.Datastores | Sort-Object) } $currDS = @() if ($metadata.Datastores) { $currDS = @($metadata.Datastores | Sort-Object) } $prevDSStr = $prevDS -join ',' $currDSStr = $currDS -join ',' if ($prevDSStr -ne $currDSStr) { [void]$changes.Add(('Datastores: [' + $prevDSStr + '] -> [' + $currDSStr + ']')) } # Compare cluster and datacenter if ([string]$prev.Cluster -ne [string]$metadata.Cluster) { [void]$changes.Add(('Cluster: ' + [string]$prev.Cluster + ' -> ' + [string]$metadata.Cluster)) } if ([string]$prev.Datacenter -ne [string]$metadata.Datacenter) { [void]$changes.Add(('Datacenter: ' + [string]$prev.Datacenter + ' -> ' + [string]$metadata.Datacenter)) } if ($changes.Count -gt 0) { [void]$changed.Add([PSCustomObject]@{ VMName = $vm.Name PowerState = $metadata.PowerState Changes = $changes -join '; ' ChangeList = $changes }) } } Write-Progress -Activity "Detecting VM Drift" -Completed # --- Step 3: Detect removed VMs --- foreach ($prevName in $previousVMs.Keys) { if (-not $currentVMNames.ContainsKey($prevName)) { $prev = $previousVMs[$prevName] [void]$removed.Add([PSCustomObject]@{ VMName = $prevName PowerState = $prev.PowerState CPU = $prev.NumCPU MemoryGB = $prev.MemoryGB Cluster = $prev.Cluster Datacenter = $prev.Datacenter }) } } } finally { if ($viConnection) { try { Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor } catch { } } } # --- Step 4: Generate HTML report --- if ($OutputPath) { Write-Verbose "Generating drift report: $OutputPath" $html = Build-DriftReportHtml -Added $added -Removed $removed -Changed $changed ` -Server $Server -StartTime $startTime -PreviousSyncDate $previousSyncDate try { $reportDir = Split-Path -Path $OutputPath -Parent if ($reportDir -and -not (Test-Path $reportDir)) { New-Item -Path $reportDir -ItemType Directory -Force | Out-Null } $html | Set-Content -Path $OutputPath -Encoding UTF8 -Force Write-Host "Drift report saved to: $OutputPath" -ForegroundColor Green } catch { Write-Warning "Failed to save drift report: $_" } } } end { # Summary Write-Host "" Write-Host "=== VM Drift Detection Summary ===" -ForegroundColor Cyan Write-Host " Previous Sync: $previousSyncDate" Write-Host " VMs Added: $($added.Count)" -ForegroundColor $(if ($added.Count -gt 0) { 'Yellow' } else { 'Green' }) Write-Host " VMs Removed: $($removed.Count)" -ForegroundColor $(if ($removed.Count -gt 0) { 'Red' } else { 'Green' }) Write-Host " VMs Changed: $($changed.Count)" -ForegroundColor $(if ($changed.Count -gt 0) { 'Yellow' } else { 'Green' }) Write-Host " Total Drift: $(($added.Count + $removed.Count + $changed.Count)) items" Write-Host "" $result = [PSCustomObject]@{ Server = $Server PreviousSyncDate = $previousSyncDate ScanDate = (Get-Date).ToString('o') Added = @($added) Removed = @($removed) Changed = @($changed) TotalDrift = $added.Count + $removed.Count + $changed.Count } $result.PSObject.TypeNames.Insert(0, 'VMAutoTagger.DriftResult') return $result } } function Build-DriftReportHtml { <# .SYNOPSIS Builds HTML content for the drift detection report. #> [CmdletBinding()] param( [array]$Added, [array]$Removed, [array]$Changed, [string]$Server, [datetime]$StartTime, [string]$PreviousSyncDate ) $totalDrift = $Added.Count + $Removed.Count + $Changed.Count # Build Added rows $addedRows = '' if ($Added.Count -gt 0) { $addedSb = [System.Text.StringBuilder]::new() foreach ($a in $Added) { [void]$addedSb.AppendLine(('<tr><td>' + $a.VMName + '</td><td>' + $a.PowerState + '</td><td>' + $a.CPU + '</td><td>' + $a.MemoryGB + 'GB</td><td>' + $a.Cluster + '</td></tr>')) } $addedRows = '<div class="card"><h2 class="drift-added">Added VMs (' + $Added.Count + ')</h2><table><tr><th>VM Name</th><th>Power State</th><th>CPU</th><th>RAM</th><th>Cluster</th></tr>' + $addedSb.ToString() + '</table></div>' } # Build Removed rows $removedRows = '' if ($Removed.Count -gt 0) { $removedSb = [System.Text.StringBuilder]::new() foreach ($r in $Removed) { [void]$removedSb.AppendLine(('<tr><td>' + $r.VMName + '</td><td>' + $r.PowerState + '</td><td>' + $r.CPU + '</td><td>' + $r.MemoryGB + 'GB</td><td>' + $r.Cluster + '</td></tr>')) } $removedRows = '<div class="card"><h2 class="drift-removed">Removed VMs (' + $Removed.Count + ')</h2><table><tr><th>VM Name</th><th>Last Power State</th><th>CPU</th><th>RAM</th><th>Last Cluster</th></tr>' + $removedSb.ToString() + '</table></div>' } # Build Changed rows $changedRows = '' if ($Changed.Count -gt 0) { $changedSb = [System.Text.StringBuilder]::new() foreach ($c in $Changed) { $encChanges = [System.Web.HttpUtility]::HtmlEncode($c.Changes) [void]$changedSb.AppendLine(('<tr><td>' + $c.VMName + '</td><td>' + $c.PowerState + '</td><td>' + $encChanges + '</td></tr>')) } $changedRows = '<div class="card"><h2 class="drift-changed">Changed VMs (' + $Changed.Count + ')</h2><table><tr><th>VM Name</th><th>Current Power State</th><th>Changes</th></tr>' + $changedSb.ToString() + '</table></div>' } $tsStr = '{0:yyyy-MM-dd HH:mm:ss}' -f $StartTime $nowStr = '{0:yyyy-MM-dd HH:mm:ss}' -f (Get-Date) $html = @' <!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"> <title>VM Drift Report</title><style> *{margin:0;padding:0;box-sizing:border-box}body{font-family:'Segoe UI',Tahoma,sans-serif;background:#0d1117;color:#c9d1d9;padding:2rem;line-height:1.6} .header{background:linear-gradient(135deg,#1a1f2e 0%,#2a1a0a 100%);padding:2rem;border-radius:8px;margin-bottom:2rem;border:1px solid #30363d} .header h1{color:#d29922;font-size:1.8rem;margin-bottom:.5rem}.header .subtitle{color:#8b949e;font-size:.95rem} .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:1rem;margin-bottom:2rem} .stat-card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.2rem;text-align:center} .stat-card .value{font-size:2rem;font-weight:bold}.stat-card .label{color:#8b949e;font-size:.85rem;text-transform:uppercase} .stat-card.added .value{color:#3fb950}.stat-card.removed .value{color:#f85149}.stat-card.changed .value{color:#d29922}.stat-card.total .value{color:#c9d1d9} .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem} .card h2{margin-bottom:1rem} .drift-added{color:#3fb950}.drift-removed{color:#f85149}.drift-changed{color:#d29922} table{width:100%;border-collapse:collapse;margin-top:.5rem}th{background:#21262d;color:#d29922;padding:.75rem 1rem;text-align:left;font-weight:600;border-bottom:2px solid #30363d} td{padding:.6rem 1rem;border-bottom:1px solid #21262d;vertical-align:top}tr:hover{background:#1c2128} .no-drift{text-align:center;padding:3rem;color:#3fb950;font-size:1.2rem} .footer{text-align:center;color:#484f58;font-size:.8rem;margin-top:2rem;padding-top:1rem;border-top:1px solid #21262d} </style></head><body> <div class="header"><h1>VM Drift Report</h1><div class="subtitle">Server: %%SERVER%% | Baseline: %%PREVSYNC%% | Scanned: %%TS%%</div></div> <div class="stats-grid"> <div class="stat-card added"><div class="value">%%ADDED%%</div><div class="label">Added</div></div> <div class="stat-card removed"><div class="value">%%REMOVED%%</div><div class="label">Removed</div></div> <div class="stat-card changed"><div class="value">%%CHANGED%%</div><div class="label">Changed</div></div> <div class="stat-card total"><div class="value">%%TOTAL%%</div><div class="label">Total Drift</div></div> </div> %%ADDEDROWS%% %%REMOVEDROWS%% %%CHANGEDROWS%% %%NODRIFT%% <div class="footer">Generated by VM-AutoTagger v1.0.0 | %%NOW%%</div> </body></html> '@ $noDrift = '' if ($totalDrift -eq 0) { $noDrift = '<div class="card"><div class="no-drift">No drift detected. Environment matches the last sync baseline.</div></div>' } $html = $html.Replace('%%SERVER%%', $Server) $html = $html.Replace('%%PREVSYNC%%', [string]$PreviousSyncDate) $html = $html.Replace('%%TS%%', $tsStr) $html = $html.Replace('%%ADDED%%', $Added.Count.ToString()) $html = $html.Replace('%%REMOVED%%', $Removed.Count.ToString()) $html = $html.Replace('%%CHANGED%%', $Changed.Count.ToString()) $html = $html.Replace('%%TOTAL%%', $totalDrift.ToString()) $html = $html.Replace('%%ADDEDROWS%%', $addedRows) $html = $html.Replace('%%REMOVEDROWS%%', $removedRows) $html = $html.Replace('%%CHANGEDROWS%%', $changedRows) $html = $html.Replace('%%NODRIFT%%', $noDrift) $html = $html.Replace('%%NOW%%', $nowStr) return $html } |