Public/Get-StaleVMs.ps1
|
function Get-StaleVMs { <# .SYNOPSIS Identifies potentially stale or abandoned VMs based on power state and activity. .DESCRIPTION Connects to a vCenter Server or ESXi host and evaluates virtual machines for staleness indicators: - VMs powered off for longer than a configurable threshold (default: 30 days) - VMs powered on but showing near-zero activity (very low CPU/RAM allocation, or no VMware Tools heartbeat) - VMs with no network connectivity that appear idle For each stale VM, the function estimates monthly waste cost based on provisioned CPU and RAM, and generates a remediation recommendation. Results can be exported to an HTML report with a dark theme dashboard showing stale VM details, waste estimates, and recommended actions. .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 evaluated. .PARAMETER Cluster Filter VMs to a specific vSphere cluster. .PARAMETER StaleThresholdDays Number of days a VM must be powered off to be considered stale. Default: 30. .PARAMETER CpuCostPerHour Cost per vCPU per hour for waste estimation. Default: $0.05. .PARAMETER RamCostPerGBHour Cost per GB RAM per hour for waste estimation. Default: $0.01. .PARAMETER OutputPath Path to save an HTML report of stale VM findings. .EXAMPLE Get-StaleVMs -Server vcenter.contoso.com Checks all VMs using default 30-day threshold. .EXAMPLE Get-StaleVMs -Server vcenter.contoso.com -StaleThresholdDays 14 -Cluster "Production" -OutputPath .\stale-report.html Checks Production cluster VMs with a 14-day threshold and generates an HTML report. .EXAMPLE $stale = Get-StaleVMs -Server vcenter.contoso.com $stale | Where-Object { $_.EstimatedWastePerMonth -gt 50 } | Format-Table VMName, DaysSincePowerOn, EstimatedWastePerMonth Finds VMs with estimated monthly waste over $50. .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()] [string]$Cluster, [Parameter()] [ValidateRange(1, 3650)] [int]$StaleThresholdDays = 30, [Parameter()] [double]$CpuCostPerHour = 0.05, [Parameter()] [double]$RamCostPerGBHour = 0.01, [Parameter()] [string]$OutputPath, [Parameter()] [ValidateSet('VMware', 'HyperV')] [string]$Hypervisor = 'VMware' ) begin { $startTime = Get-Date $staleVMs = [System.Collections.ArrayList]::new() Write-Verbose "Stale VM scan starting at $startTime (threshold: $StaleThresholdDays days)" } 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 { $getVMParams = @{ Server = $Server; ErrorAction = 'Stop' } if ($Cluster) { $getVMParams['Location'] = Get-Cluster -Name $Cluster -Server $Server -ErrorAction Stop } $allVMs = @(Get-VM @getVMParams) } # Apply VM name filter 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 "Evaluating $($allVMs.Count) VMs for staleness..." # --- Evaluate each VM --- $vmIndex = 0 foreach ($vm in $allVMs) { $vmIndex++ Write-Progress -Activity "Scanning for Stale VMs" -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 "Could not get metadata for $($vm.Name): $_" continue } $isStale = $false $staleReason = '' $daysSincePowerOn = $null # --- Check 1: Powered-off VMs --- if ($metadata.PowerState -eq 'PoweredOff') { if ($null -ne $metadata.LastPoweredOn) { $daysSincePowerOn = [math]::Round(((Get-Date) - $metadata.LastPoweredOn).TotalDays, 0) } elseif ($null -ne $metadata.CreatedDate) { # No power event found -- use creation date as fallback $daysSincePowerOn = [math]::Round(((Get-Date) - $metadata.CreatedDate).TotalDays, 0) } if ($null -ne $daysSincePowerOn -and $daysSincePowerOn -ge $StaleThresholdDays) { $isStale = $true $staleReason = "Powered off for $daysSincePowerOn days (threshold: $StaleThresholdDays)" } } # --- Check 2: Powered-on but idle indicators --- if (-not $isStale -and $metadata.PowerState -eq 'PoweredOn') { # Check for missing VMware Tools heartbeat -- strong idle indicator $toolsMissing = ($metadata.ToolsStatus -eq 'toolsNotInstalled' -or $metadata.ToolsStatus -eq 'toolsNotRunning') $noNetwork = ($metadata.Networks.Count -eq 0 -or ($metadata.Networks.Count -eq 1 -and $metadata.Networks[0] -eq 'Disconnected')) if ($toolsMissing -and $noNetwork) { $isStale = $true $staleReason = 'Powered on but no VMware Tools and no network -- possibly abandoned' # Estimate powered on since creation or boot if ($null -ne $metadata.LastPoweredOn) { $daysSincePowerOn = [math]::Round(((Get-Date) - $metadata.LastPoweredOn).TotalDays, 0) } } } if (-not $isStale) { continue } # --- Calculate waste cost --- $cpuMonthlyCost = $metadata.NumCPU * $CpuCostPerHour * 24 * 30 $ramMonthlyCost = $metadata.MemoryGB * $RamCostPerGBHour * 24 * 30 $totalMonthlyWaste = [math]::Round($cpuMonthlyCost + $ramMonthlyCost, 2) # --- Generate recommendation --- $recommendation = Get-StaleVMRecommendation -DaysSincePowerOn $daysSincePowerOn ` -PowerState $metadata.PowerState ` -StorageGB $metadata.ProvisionedSpaceGB ` -StaleReason $staleReason $staleEntry = [PSCustomObject]@{ VMName = $vm.Name PowerState = $metadata.PowerState DaysSincePowerOn = $daysSincePowerOn StaleReason = $staleReason CPU = $metadata.NumCPU MemoryGB = $metadata.MemoryGB ProvisionedStorageGB = $metadata.ProvisionedSpaceGB UsedStorageGB = $metadata.UsedSpaceGB ToolsStatus = $metadata.ToolsStatus SnapshotCount = $metadata.SnapshotCount Cluster = $metadata.Cluster Datacenter = $metadata.Datacenter Host = $metadata.Host CreatedDate = $metadata.CreatedDate LastPoweredOn = $metadata.LastPoweredOn Networks = $metadata.Networks EstimatedWastePerMonth = $totalMonthlyWaste Recommendation = $recommendation } $staleEntry.PSObject.TypeNames.Insert(0, 'VMAutoTagger.StaleVM') [void]$staleVMs.Add($staleEntry) } Write-Progress -Activity "Scanning for Stale VMs" -Completed } finally { if ($viConnection) { try { Disconnect-Hypervisor -Server $Server -Hypervisor $Hypervisor } catch { } } } # --- Generate HTML report --- if ($OutputPath -and $staleVMs.Count -gt 0) { Write-Verbose "Generating stale VM report: $OutputPath" $html = Build-StaleVMReportHtml -StaleVMs $staleVMs -Server $Server -StartTime $startTime ` -ThresholdDays $StaleThresholdDays 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 "Stale VM report saved to: $OutputPath" -ForegroundColor Green } catch { Write-Warning "Failed to save stale VM report: $_" } } } end { # Summary $totalWaste = ($staleVMs | Measure-Object -Property EstimatedWastePerMonth -Sum).Sum if ($null -eq $totalWaste) { $totalWaste = 0 } $totalStorage = ($staleVMs | Measure-Object -Property ProvisionedStorageGB -Sum).Sum if ($null -eq $totalStorage) { $totalStorage = 0 } Write-Host "" Write-Host "=== Stale VM Scan Summary ===" -ForegroundColor Cyan Write-Host " Stale VMs Found: $($staleVMs.Count)" Write-Host " Total Storage Waste: $([math]::Round($totalStorage, 1)) GB" Write-Host " Est. Monthly Waste: `$$([math]::Round($totalWaste, 2))" Write-Host " Threshold: $StaleThresholdDays days" Write-Host "" return @($staleVMs) } } function Get-StaleVMRecommendation { <# .SYNOPSIS Generates a remediation recommendation string for a stale VM. #> [CmdletBinding()] param( [int]$DaysSincePowerOn, [string]$PowerState, [double]$StorageGB, [string]$StaleReason ) if ($null -eq $DaysSincePowerOn) { $DaysSincePowerOn = 0 } if ($DaysSincePowerOn -gt 180) { return 'DECOMMISSION: VM has been inactive for over 6 months. Verify with owner and delete to reclaim resources.' } elseif ($DaysSincePowerOn -gt 90) { return 'ARCHIVE: VM inactive for over 90 days. Export to OVA/OVF for cold storage and remove from cluster.' } elseif ($DaysSincePowerOn -gt 60) { return 'REVIEW: VM inactive for over 60 days. Contact VM owner to confirm whether it is still needed.' } elseif ($PowerState -eq 'PoweredOn') { return 'INVESTIGATE: VM is powered on but shows no activity. Verify it is functional and serving a purpose.' } else { return 'MONITOR: VM recently became stale. Check back after the next review cycle.' } } function Build-StaleVMReportHtml { <# .SYNOPSIS Builds the HTML report for stale VM findings. #> [CmdletBinding()] param( [array]$StaleVMs, [string]$Server, [datetime]$StartTime, [int]$ThresholdDays ) $totalWaste = ($StaleVMs | Measure-Object -Property EstimatedWastePerMonth -Sum).Sum if ($null -eq $totalWaste) { $totalWaste = 0 } $totalStorage = ($StaleVMs | Measure-Object -Property ProvisionedStorageGB -Sum).Sum if ($null -eq $totalStorage) { $totalStorage = 0 } # Build VM rows using single-quoted concatenation $rowList = [System.Collections.ArrayList]::new() foreach ($svm in $StaleVMs) { $stateClass = switch ($svm.PowerState) { 'PoweredOn' { 'status-good' }; 'PoweredOff' { 'status-bad' }; default { 'status-warn' } } $lastOn = if ($null -ne $svm.LastPoweredOn) { '{0:yyyy-MM-dd}' -f $svm.LastPoweredOn } else { 'Unknown' } $daysStr = if ($null -ne $svm.DaysSincePowerOn) { [string]$svm.DaysSincePowerOn } else { 'N/A' } $wasteClass = if ($svm.EstimatedWastePerMonth -gt 100) { 'text-warning' } else { '' } $encRec = [System.Web.HttpUtility]::HtmlEncode($svm.Recommendation) [void]$rowList.Add(('<tr><td>' + $svm.VMName + '</td><td><span class="' + $stateClass + '">' + $svm.PowerState + '</span></td><td>' + $lastOn + '</td><td>' + $daysStr + '</td><td>' + $svm.CPU + '</td><td>' + $svm.MemoryGB + 'GB</td><td>' + $svm.ProvisionedStorageGB + 'GB</td><td class="' + $wasteClass + '">$' + $svm.EstimatedWastePerMonth + '/mo</td><td class="recommendation">' + $encRec + '</td></tr>')) } $vmRows = $rowList -join "`n" # Build waste distribution by recommendation category $recGroups = $StaleVMs | Group-Object { ($_.Recommendation -split ':')[0].Trim() } $distSb = [System.Text.StringBuilder]::new() foreach ($rg in ($recGroups | Sort-Object Count -Descending)) { $groupWaste = ($rg.Group | Measure-Object -Property EstimatedWastePerMonth -Sum).Sum $pct = if ($StaleVMs.Count -gt 0) { [math]::Round(($rg.Count / $StaleVMs.Count) * 100, 1) } else { 0 } [void]$distSb.Append(('<div class="bar-row"><span class="bar-label">' + $rg.Name + ' (' + $rg.Count + ')</span><div class="bar-track"><div class="bar-fill" style="width: ' + $pct + '%"></div></div><span class="bar-value">$' + [math]::Round($groupWaste, 2) + '/mo</span></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>Stale VM 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(180px,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;color:#d29922}.stat-card .label{color:#8b949e;font-size:.85rem;text-transform:uppercase} .stat-card.waste .value{color:#f85149} .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:1.5rem;margin-bottom:1.5rem} .card h2{color:#d29922;margin-bottom:1rem} 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;font-size:.9rem}tr:hover{background:#1c2128} .status-good{color:#3fb950}.status-bad{color:#f85149}.status-warn{color:#d29922} .text-warning{color:#d29922;font-weight:600} .recommendation{font-size:.8rem;color:#8b949e;max-width:300px} .bar-chart{padding:.5rem 0}.bar-row{display:flex;align-items:center;margin-bottom:.5rem} .bar-label{width:180px;font-size:.85rem;color:#c9d1d9;flex-shrink:0} .bar-track{flex:1;height:20px;background:#21262d;border-radius:4px;overflow:hidden;margin:0 .75rem} .bar-fill{height:100%;background:linear-gradient(90deg,#d29922,#e8b84a);border-radius:4px;min-width:2px} .bar-value{width:100px;font-size:.8rem;color:#8b949e;text-align:right;flex-shrink:0} .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>Stale VM Report</h1><div class="subtitle">Server: %%SERVER%% | Threshold: %%THRESHOLD%% days | Generated: %%TS%%</div></div> <div class="stats-grid"> <div class="stat-card"><div class="value">%%COUNT%%</div><div class="label">Stale VMs</div></div> <div class="stat-card waste"><div class="value">$%%WASTE%%</div><div class="label">Est. Monthly Waste</div></div> <div class="stat-card"><div class="value">%%STORAGE%%GB</div><div class="label">Wasted Storage</div></div> <div class="stat-card"><div class="value">%%THRESHOLD%%d</div><div class="label">Threshold</div></div> </div> <div class="card"><h2>Waste by Recommendation</h2><div class="bar-chart">%%DISTCHART%%</div></div> <div class="card"><h2>Stale VM Details</h2> <table><tr><th>VM Name</th><th>Power State</th><th>Last Powered On</th><th>Days</th><th>CPU</th><th>RAM</th><th>Storage</th><th>Est. Waste</th><th>Recommendation</th></tr> %%VMROWS%% </table></div> <div class="footer">Generated by VM-AutoTagger v1.0.0 | %%NOW%%</div> </body></html> '@ $html = $html.Replace('%%SERVER%%', $Server) $html = $html.Replace('%%THRESHOLD%%', $ThresholdDays.ToString()) $html = $html.Replace('%%TS%%', $tsStr) $html = $html.Replace('%%COUNT%%', $StaleVMs.Count.ToString()) $html = $html.Replace('%%WASTE%%', [math]::Round($totalWaste, 2).ToString()) $html = $html.Replace('%%STORAGE%%', [math]::Round($totalStorage, 1).ToString()) $html = $html.Replace('%%DISTCHART%%', $distSb.ToString()) $html = $html.Replace('%%VMROWS%%', $vmRows) $html = $html.Replace('%%NOW%%', $nowStr) return $html } |