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 } |