#Requires -Version 5.1 <# .SYNOPSIS Destroys ephemeral build VMs that were not cleaned up (e.g. after host power loss). .DESCRIPTION Scans F:\CI\BuildVMs\ for clone directories older than -MaxAgeHours. For each stale directory, stops the VM (hard), deletes via vmrun deleteVM, then removes the directory. Run as a daily scheduled task or on host startup to prevent disk accumulation. Safe to run while CI jobs are active — only touches VMs older than MaxAgeHours. .PARAMETER CloneBaseDir Directory containing ephemeral VM clones. Default: F:\CI\BuildVMs .PARAMETER MaxAgeHours Age threshold in hours. Directories with LastWriteTime older than this are treated as orphaned. Must exceed the longest expected build duration. Default: 4 (runner.timeout is 2h — 4h gives double margin) .PARAMETER VmrunPath Path to vmrun.exe. Default: C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe .PARAMETER WhatIf List orphaned VMs without destroying them. .EXAMPLE # Dry run .\Cleanup-OrphanedBuildVMs.ps1 -WhatIf # Live cleanup (run elevated) .\Cleanup-OrphanedBuildVMs.ps1 #> [CmdletBinding(SupportsShouldProcess)] param( [string] $CloneBaseDir = 'F:\CI\BuildVMs', [ValidateRange(1, 168)] [int] $MaxAgeHours = 4, [string] $VmrunPath = 'C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Continue' # Don't abort on individual VM errors if (-not (Test-Path $CloneBaseDir)) { Write-Host "[Cleanup] Clone base dir not found: $CloneBaseDir — nothing to do." exit 0 } if (-not (Test-Path $VmrunPath -PathType Leaf)) { Write-Warning "[Cleanup] vmrun.exe not found at: $VmrunPath" Write-Warning " Skipping vmrun stop/delete — will attempt directory removal only." } $cutoff = (Get-Date).AddHours(-$MaxAgeHours) $orphans = @(Get-ChildItem -Path $CloneBaseDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -lt $cutoff }) if (-not $orphans) { Write-Host "[Cleanup] No orphaned VMs found (threshold: $MaxAgeHours h)." exit 0 } Write-Host "[Cleanup] Found $($orphans.Count) orphaned VM director$(if ($orphans.Count -eq 1) {'y'} else {'ies'}) older than $MaxAgeHours h." foreach ($dir in $orphans) { $vmx = Get-ChildItem -Path $dir.FullName -Filter '*.vmx' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($PSCmdlet.ShouldProcess($dir.FullName, "Destroy orphaned VM")) { Write-Host "[Cleanup] Processing: $($dir.FullName)" if ($vmx -and (Test-Path $VmrunPath -PathType Leaf)) { # Hard stop — don't wait for graceful shutdown on an orphan & $VmrunPath -T ws stop $vmx.FullName hard 2>&1 | Out-Null Start-Sleep -Seconds 2 $deleteOut = & $VmrunPath -T ws deleteVM $vmx.FullName 2>&1 if ($LASTEXITCODE -ne 0) { Write-Warning "[Cleanup] vmrun deleteVM failed (exit $LASTEXITCODE): $deleteOut" Write-Warning " Falling back to directory removal." } } elseif (-not $vmx) { Write-Warning "[Cleanup] No .vmx found in $($dir.FullName) — removing directory only." } Remove-Item $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue if (Test-Path $dir.FullName) { Write-Warning "[Cleanup] Could not fully remove: $($dir.FullName) — manual cleanup needed." } else { Write-Host "[Cleanup] Removed: $($dir.FullName)" } } else { Write-Host "[Cleanup] WhatIf: would destroy $($dir.FullName) (age: $([int]((Get-Date) - $dir.LastWriteTime).TotalHours) h)" } } Write-Host "[Cleanup] Done."