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
}