Public/Get-VMCompliance.ps1

function Get-VMCompliance {
    <#
    .SYNOPSIS
        Checks VMs against compliance rules and returns findings with severity ratings.
    .DESCRIPTION
        Connects to a vCenter Server, ESXi host, or Hyper-V host and evaluates virtual machines against
        a set of built-in compliance rules. Each rule checks for a specific condition
        (e.g., outdated VMware Tools, stale snapshots, oversized VMs) and returns
        findings with severity (Critical, Warning, Info), detail messages, and
        remediation recommendations.
 
        Built-in compliance rules:
        - ToolsNotInstalled: VMware Tools not installed (Critical)
        - ToolsOutdated: VMware Tools version is outdated (Warning)
        - StaleSnapshots: Snapshots older than threshold (Warning/Critical based on age)
        - NoBackupTag: VM missing a Backup-Policy tag (Info)
        - OversizedVM: VM exceeds CPU or RAM thresholds (Warning)
        - OrphanedVM: VM powered off for more than threshold days (Warning)
        - SingleDatastore: VM spans multiple datastores (Info)
        - NoNetwork: VM has no network adapter connected (Info)
 
        Results can be exported to an HTML report for review and remediation tracking.
    .PARAMETER Server
        The vCenter Server, ESXi host, or Hyper-V hostname/IP to connect to.
    .PARAMETER Credential
        PSCredential for authentication.
    .PARAMETER VMName
        One or more VM names to check. Supports wildcards. If omitted, all VMs are checked.
    .PARAMETER Rules
        One or more rule names to evaluate. If omitted, all rules are checked.
        Valid values: ToolsNotInstalled, ToolsOutdated, StaleSnapshots, NoBackupTag,
        OversizedVM, OrphanedVM, SingleDatastore, NoNetwork
    .PARAMETER SnapshotAgeDays
        The age threshold in days for the StaleSnapshots rule. Default: 7.
    .PARAMETER MaxCPU
        Maximum vCPU count before OversizedVM is triggered. Default: 16.
    .PARAMETER MaxMemoryGB
        Maximum RAM in GB before OversizedVM is triggered. Default: 128.
    .PARAMETER OrphanDays
        Days powered off before OrphanedVM is triggered. Default: 90.
    .PARAMETER OutputPath
        Path to save an HTML compliance report.
    .EXAMPLE
        Get-VMCompliance -Server vcenter.contoso.com
 
        Checks all VMs against all compliance rules using default thresholds.
    .EXAMPLE
        Get-VMCompliance -Server vcenter.contoso.com -VMName "DB*" -Rules StaleSnapshots,OversizedVM -SnapshotAgeDays 3 -OutputPath .\compliance.html
 
        Checks database VMs for stale snapshots (3-day threshold) and oversized configurations.
    .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[]]$VMName,

        [Parameter()]
        [ValidateSet('ToolsNotInstalled', 'ToolsOutdated', 'StaleSnapshots', 'NoBackupTag',
                      'OversizedVM', 'OrphanedVM', 'SingleDatastore', 'NoNetwork')]
        [string[]]$Rules,

        [Parameter()]
        [ValidateRange(1, 365)]
        [int]$SnapshotAgeDays = 7,

        [Parameter()]
        [ValidateRange(1, 1024)]
        [int]$MaxCPU = 16,

        [Parameter()]
        [ValidateRange(1, 4096)]
        [int]$MaxMemoryGB = 128,

        [Parameter()]
        [ValidateRange(1, 3650)]
        [int]$OrphanDays = 90,

        [Parameter()]
        [string]$OutputPath,

        [Parameter()]
        [ValidateSet('VMware', 'HyperV')]
        [string]$Hypervisor = 'VMware'
    )

    begin {
        $startTime = Get-Date
        $findings = [System.Collections.ArrayList]::new()

        # Default to all rules if none specified
        if (-not $Rules) {
            $Rules = @('ToolsNotInstalled', 'ToolsOutdated', 'StaleSnapshots', 'NoBackupTag',
                       'OversizedVM', 'OrphanedVM', 'SingleDatastore', 'NoNetwork')
        }

        Write-Verbose "Running compliance check with rules: $($Rules -join ', ')"
    }

    process {
        # --- Connect to hypervisor ---
        $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
        }

        try {
            # --- Get VMs ---
            Write-Verbose "Retrieving virtual machines..."
            if ($Hypervisor -eq 'HyperV') {
                $getVMParams = @{ ErrorAction = 'Stop' }
                if ($Server -ne 'localhost' -and $Server -ne $env:COMPUTERNAME -and $Server -ne '.') {
                    $getVMParams['ComputerName'] = $Server
                }
                $allVMs = @(Hyper-V\Get-VM @getVMParams)
            }
            else {
                $allVMs = @(Get-VM -Server $Server -ErrorAction Stop)
            }

            if ($VMName) {
                $filteredVMs = [System.Collections.ArrayList]::new()
                foreach ($pattern in $VMName) {
                    $matched = @($allVMs | Where-Object { $_.Name -like $pattern })
                    foreach ($vm in $matched) {
                        if ($filteredVMs.Name -notcontains $vm.Name) {
                            [void]$filteredVMs.Add($vm)
                        }
                    }
                }
                $allVMs = $filteredVMs.ToArray()
            }

            Write-Verbose "Checking $($allVMs.Count) VMs..."

            # --- Process each VM ---
            $vmIndex = 0
            foreach ($vm in $allVMs) {
                $vmIndex++
                Write-Progress -Activity "Checking Compliance" -Status "$($vm.Name) ($vmIndex of $($allVMs.Count))" -PercentComplete ([math]::Round(($vmIndex / $allVMs.Count) * 100))

                $metadata = $null
                try {
                    if ($Hypervisor -eq 'HyperV') {
                        $metadata = Get-HyperVMetadata -VM $vm -IncludeCreationDate
                    }
                    else {
                        $metadata = Get-VMMetadata -VM $vm -IncludeCreationDate
                    }
                }
                catch {
                    Write-Warning "Failed to collect metadata for $($vm.Name): $_"
                    continue
                }

                # --- Rule: ToolsNotInstalled ---
                if ('ToolsNotInstalled' -in $Rules) {
                    if ($metadata.ToolsStatus -eq 'toolsNotInstalled' -or
                        $metadata.ToolsStatus -eq 'toolsNotRunning') {
                        [void]$findings.Add([PSCustomObject]@{
                            VMName         = $vm.Name
                            Rule           = 'ToolsNotInstalled'
                            Severity       = 'Critical'
                            Detail         = "VMware Tools status: $($metadata.ToolsStatus)"
                            Recommendation = 'Install VMware Tools for guest OS management, graceful shutdown, and performance optimization.'
                            PowerState     = $metadata.PowerState
                            Cluster        = $metadata.Cluster
                            Datacenter     = $metadata.Datacenter
                        })
                    }
                }

                # --- Rule: ToolsOutdated ---
                if ('ToolsOutdated' -in $Rules) {
                    if ($metadata.ToolsStatus -eq 'toolsOld') {
                        [void]$findings.Add([PSCustomObject]@{
                            VMName         = $vm.Name
                            Rule           = 'ToolsOutdated'
                            Severity       = 'Warning'
                            Detail         = "VMware Tools version is outdated (version: $($metadata.ToolsVersion))."
                            Recommendation = 'Update VMware Tools to the latest version during the next maintenance window.'
                            PowerState     = $metadata.PowerState
                            Cluster        = $metadata.Cluster
                            Datacenter     = $metadata.Datacenter
                        })
                    }
                }

                # --- Rule: StaleSnapshots ---
                if ('StaleSnapshots' -in $Rules) {
                    if ($metadata.SnapshotCount -gt 0 -and $null -ne $metadata.OldestSnapshotAge) {
                        if ($metadata.OldestSnapshotAge -gt $SnapshotAgeDays) {
                            $severity = if ($metadata.OldestSnapshotAge -gt 30) { 'Critical' } else { 'Warning' }
                            $snapshotNames = ($metadata.SnapshotDetails | ForEach-Object {
                                "$($_.Name) (age: $($_.AgeDays)d, size: $($_.SizeGB)GB)"
                            }) -join '; '

                            [void]$findings.Add([PSCustomObject]@{
                                VMName         = $vm.Name
                                Rule           = 'StaleSnapshots'
                                Severity       = $severity
                                Detail         = "$($metadata.SnapshotCount) snapshot(s), oldest is $([math]::Round($metadata.OldestSnapshotAge, 0)) days old. Snapshots: $snapshotNames"
                                Recommendation = 'Consolidate or delete stale snapshots to reclaim storage and prevent performance degradation.'
                                PowerState     = $metadata.PowerState
                                Cluster        = $metadata.Cluster
                                Datacenter     = $metadata.Datacenter
                            })
                        }
                    }
                }

                # --- Rule: NoBackupTag ---
                if ('NoBackupTag' -in $Rules) {
                    $hasBackupTag = $false
                    try {
                        if ($Hypervisor -eq 'HyperV') {
                            $hvTags = Get-HyperVNotesTags -VM $vm
                            $hasBackupTag = $hvTags.ContainsKey('Backup-Policy')
                        }
                        else {
                            $tagAssignments = @(Get-TagAssignment -Entity $vm -ErrorAction SilentlyContinue)
                            $hasBackupTag = ($tagAssignments | Where-Object { $_.Tag.Category.Name -eq 'Backup-Policy' }).Count -gt 0
                        }
                    }
                    catch {
                        Write-Verbose "Could not check tags for $($vm.Name): $_"
                    }

                    if (-not $hasBackupTag) {
                        [void]$findings.Add([PSCustomObject]@{
                            VMName         = $vm.Name
                            Rule           = 'NoBackupTag'
                            Severity       = 'Info'
                            Detail         = "VM has no tag in the 'Backup-Policy' category."
                            Recommendation = 'Assign a Backup-Policy tag to ensure the VM is included in backup schedules.'
                            PowerState     = $metadata.PowerState
                            Cluster        = $metadata.Cluster
                            Datacenter     = $metadata.Datacenter
                        })
                    }
                }

                # --- Rule: OversizedVM ---
                if ('OversizedVM' -in $Rules) {
                    $oversizedReasons = @()
                    if ($metadata.NumCPU -gt $MaxCPU) {
                        $oversizedReasons += "$($metadata.NumCPU) vCPUs (max: $MaxCPU)"
                    }
                    if ($metadata.MemoryGB -gt $MaxMemoryGB) {
                        $oversizedReasons += "$($metadata.MemoryGB)GB RAM (max: ${MaxMemoryGB}GB)"
                    }

                    if ($oversizedReasons.Count -gt 0) {
                        [void]$findings.Add([PSCustomObject]@{
                            VMName         = $vm.Name
                            Rule           = 'OversizedVM'
                            Severity       = 'Warning'
                            Detail         = "VM exceeds resource thresholds: $($oversizedReasons -join ', ')."
                            Recommendation = 'Review VM sizing. Consider right-sizing based on actual utilization metrics.'
                            PowerState     = $metadata.PowerState
                            Cluster        = $metadata.Cluster
                            Datacenter     = $metadata.Datacenter
                        })
                    }
                }

                # --- Rule: OrphanedVM ---
                if ('OrphanedVM' -in $Rules) {
                    if ($metadata.PowerState -eq 'PoweredOff') {
                        $daysSincePower = $null
                        if ($null -ne $metadata.LastPoweredOn) {
                            $daysSincePower = [math]::Round(((Get-Date) - $metadata.LastPoweredOn).TotalDays, 0)
                        }
                        elseif ($null -ne $metadata.CreatedDate) {
                            # If never powered on data is available, use creation date as proxy
                            $daysSincePower = [math]::Round(((Get-Date) - $metadata.CreatedDate).TotalDays, 0)
                        }

                        if ($null -ne $daysSincePower -and $daysSincePower -gt $OrphanDays) {
                            [void]$findings.Add([PSCustomObject]@{
                                VMName         = $vm.Name
                                Rule           = 'OrphanedVM'
                                Severity       = 'Warning'
                                Detail         = "VM has been powered off for approximately $daysSincePower days (threshold: $OrphanDays days). Provisioned storage: $($metadata.ProvisionedSpaceGB)GB."
                                Recommendation = 'Verify if this VM is still needed. Consider decommissioning or archiving to reclaim resources.'
                                PowerState     = $metadata.PowerState
                                Cluster        = $metadata.Cluster
                                Datacenter     = $metadata.Datacenter
                            })
                        }
                    }
                }

                # --- Rule: SingleDatastore (actually checks multi-datastore spanning) ---
                if ('SingleDatastore' -in $Rules) {
                    if ($metadata.Datastores.Count -gt 1) {
                        [void]$findings.Add([PSCustomObject]@{
                            VMName         = $vm.Name
                            Rule           = 'SingleDatastore'
                            Severity       = 'Info'
                            Detail         = "VM spans $($metadata.Datastores.Count) datastores: $($metadata.Datastores -join ', '). This can complicate Storage vMotion and backups."
                            Recommendation = 'Consider consolidating VM files to a single datastore for simplified management.'
                            PowerState     = $metadata.PowerState
                            Cluster        = $metadata.Cluster
                            Datacenter     = $metadata.Datacenter
                        })
                    }
                }

                # --- Rule: NoNetwork ---
                if ('NoNetwork' -in $Rules) {
                    if ($metadata.Networks.Count -eq 0 -or ($metadata.Networks.Count -eq 1 -and $metadata.Networks[0] -eq 'Disconnected')) {
                        [void]$findings.Add([PSCustomObject]@{
                            VMName         = $vm.Name
                            Rule           = 'NoNetwork'
                            Severity       = 'Info'
                            Detail         = "VM has no connected network adapter."
                            Recommendation = 'Verify if network connectivity is intentionally disabled or if this is a configuration issue.'
                            PowerState     = $metadata.PowerState
                            Cluster        = $metadata.Cluster
                            Datacenter     = $metadata.Datacenter
                        })
                    }
                }
            }

            Write-Progress -Activity "Checking Compliance" -Completed
        }
        finally {
            if ($viConnection) {
                try {
                    Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor
                }
                catch { }
            }
        }

        # --- Generate HTML report if requested ---
        if ($OutputPath -and $findings.Count -gt 0) {
            Write-Verbose "Generating compliance report: $OutputPath"
            $html = Build-ComplianceReportHtml -Findings $findings -Server $Server -StartTime $startTime -Rules $Rules
            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 "Compliance report saved to: $OutputPath" -ForegroundColor Green
            }
            catch {
                Write-Warning "Failed to save compliance report: $_"
            }
        }
    }

    end {
        # Summary
        $criticalCount = @($findings | Where-Object { $_.Severity -eq 'Critical' }).Count
        $warningCount  = @($findings | Where-Object { $_.Severity -eq 'Warning' }).Count
        $infoCount     = @($findings | Where-Object { $_.Severity -eq 'Info' }).Count

        Write-Host ""
        Write-Host "=== Compliance Check Summary ===" -ForegroundColor Cyan
        Write-Host " Critical: $criticalCount" -ForegroundColor $(if ($criticalCount -gt 0) { 'Red' } else { 'Green' })
        Write-Host " Warning: $warningCount" -ForegroundColor $(if ($warningCount -gt 0) { 'Yellow' } else { 'Green' })
        Write-Host " Info: $infoCount" -ForegroundColor Gray
        Write-Host " Total: $($findings.Count) finding(s)"
        Write-Host ""

        # Add type name to each finding
        foreach ($f in $findings) {
            $f.PSObject.TypeNames.Insert(0, 'VMAutoTagger.ComplianceFinding')
        }

        return @($findings)
    }
}

function Build-ComplianceReportHtml {
    <#
    .SYNOPSIS
        Builds HTML content for the compliance report.
    #>

    [CmdletBinding()]
    param(
        [array]$Findings,
        [string]$Server,
        [datetime]$StartTime,
        [string[]]$Rules
    )

    $criticalCount = @($Findings | Where-Object { $_.Severity -eq 'Critical' }).Count
    $warningCount  = @($Findings | Where-Object { $_.Severity -eq 'Warning' }).Count
    $infoCount     = @($Findings | Where-Object { $_.Severity -eq 'Info' }).Count
    $uniqueVMs     = @($Findings | Select-Object -Property VMName -Unique).Count

    # Group by rule
    $ruleGroups = $Findings | Group-Object -Property Rule | Sort-Object Count -Descending

    $rcSb = [System.Text.StringBuilder]::new()
    foreach ($rg in $ruleGroups) {
        $ruleName = $rg.Name
        $ruleFindings = $rg.Group
        $ruleSeverity = ($ruleFindings | Select-Object -First 1).Severity
        $severityClass = switch ($ruleSeverity) { 'Critical' { 'severity-critical' }; 'Warning' { 'severity-warning' }; default { 'severity-info' } }
        [void]$rcSb.Append(('<div class="card"><div class="card-header"><h2><span class="' + $severityClass + '">' + $ruleName + '</span></h2><span class="card-meta">' + $ruleFindings.Count + ' finding(s)</span></div><table><tr><th>VM Name</th><th>Severity</th><th>Detail</th><th>Recommendation</th></tr>'))
        foreach ($rf in $ruleFindings) {
            $sevClass = switch ($rf.Severity) { 'Critical' { 'severity-critical' }; 'Warning' { 'severity-warning' }; default { 'severity-info' } }
            $encD = [System.Web.HttpUtility]::HtmlEncode($rf.Detail)
            $encR = [System.Web.HttpUtility]::HtmlEncode($rf.Recommendation)
            [void]$rcSb.Append(('<tr><td>' + $rf.VMName + '</td><td><span class="' + $sevClass + '">' + $rf.Severity + '</span></td><td>' + $encD + '</td><td>' + $encR + '</td></tr>'))
        }
        [void]$rcSb.AppendLine('</table></div>')
    }

    $tsStr = '{0:yyyy-MM-dd HH:mm:ss}' -f $StartTime
    $nowStr = '{0:yyyy-MM-dd HH:mm:ss}' -f (Get-Date)
    $rulesStr = $Rules -join ', '

    $html = @'
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>VM Compliance 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.critical .value{color:#f85149}.stat-card.warning .value{color:#d29922}.stat-card.info .value{color:#58a6ff}.stat-card.total .value{color:#c9d1d9}
.card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
.card-header{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:1rem}.card-header h2{color:#d29922}.card-meta{color:#8b949e;font-size:.85rem}
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}
.severity-critical{color:#f85149;font-weight:600}.severity-warning{color:#d29922;font-weight:600}.severity-info{color:#58a6ff}
.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 Compliance Report</h1><div class="subtitle">Server: %%SERVER%% | Generated: %%TS%% | Rules: %%RULES%%</div></div>
<div class="stats-grid">
<div class="stat-card critical"><div class="value">%%CRIT%%</div><div class="label">Critical</div></div>
<div class="stat-card warning"><div class="value">%%WARN%%</div><div class="label">Warning</div></div>
<div class="stat-card info"><div class="value">%%INFO%%</div><div class="label">Info</div></div>
<div class="stat-card total"><div class="value">%%VMS%%</div><div class="label">Affected VMs</div></div>
</div>%%RULECARDS%%
<div class="footer">Generated by VM-AutoTagger v1.0.0 | %%NOW%%</div>
</body></html>
'@

    $html = $html.Replace('%%SERVER%%', $Server).Replace('%%TS%%', $tsStr).Replace('%%RULES%%', $rulesStr)
    $html = $html.Replace('%%CRIT%%', $criticalCount.ToString()).Replace('%%WARN%%', $warningCount.ToString())
    $html = $html.Replace('%%INFO%%', $infoCount.ToString()).Replace('%%VMS%%', $uniqueVMs.ToString())
    $html = $html.Replace('%%RULECARDS%%', $rcSb.ToString()).Replace('%%NOW%%', $nowStr)
    return $html
}