<# .SYNOPSIS Daily VM metadata report for PowerBI trending and hardware capacity planning. .DESCRIPTION Collects VM metadata from vCenter including compute, storage, OS, and VMware Tools information using only data available within vCenter (no direct guest connections). Exports a timestamped CSV each run -- append daily runs to build a historical dataset suitable for PowerBI trend analysis and physical hardware purchasing decisions. .PARAMETER vCenterServers One or more vCenter server hostnames. Defaults to itdvmvc1.nd.gov and itdvmvc2.nd.gov. .PARAMETER DatacenterFilter Wildcard filter applied to datacenter names. Defaults to 'Primary*'. .PARAMETER OutputPath Directory where CSV and log files are written. Defaults to C:\ITDSCRIPT\Reports\VMMetadata. .PARAMETER CredentialPath Path to a saved PSCredential XML file for unattended/scheduled runs. Create one interactively with: Get-Credential | Export-Clixml -Path C:\ITDSCRIPT\Creds\vCenter.xml When omitted the script prompts for credentials. .EXAMPLE # Interactive run .\VMware-VMDailyMetadataReport.ps1 .EXAMPLE # Scheduled / unattended run .\VMware-VMDailyMetadataReport.ps1 -CredentialPath 'C:\ITDSCRIPT\Creds\vCenter.xml' .NOTES Run the following DDL once to create the destination table: DROP TABLE IF EXISTS [dbo].[VMware_Trends_VM] CREATE TABLE [dbo].[VMware_Trends_VM] ( [ReportDate] DATETIME2 NOT NULL, [VMName] NVARCHAR(255) NOT NULL, [Datacenter] NVARCHAR(100) NULL, [Cluster] NVARCHAR(100) NULL, [PowerState] NVARCHAR(50) NULL, [IsSRMPlaceholder] BIT NOT NULL, [StoragePlatform] NVARCHAR(100) NULL, [GuestOS] NVARCHAR(255) NULL, [vCPUs] INT NULL, [MemoryGB] DECIMAL(10,2) NULL, [ProvisionedSpaceGB] DECIMAL(12,2) NULL, [UsedSpaceGB] DECIMAL(12,2) NULL, [GuestDiskCapacityGB] DECIMAL(12,2) NULL, [GuestDiskUsedGB] DECIMAL(12,2) NULL, [ToolsRunningStatus] NVARCHAR(100) NULL, [ToolsVersionStatus] NVARCHAR(100) NULL, [ToolsVersion] NVARCHAR(50) NULL, [Tag_DRProtection] NVARCHAR(100) NULL, [Tag_AppName] NVARCHAR(255) NULL, [Tag_VRDatastores] NVARCHAR(255) NULL, [Tag_VRRPO] NVARCHAR(100) NULL, [Tag_DTAP] NVARCHAR(50) NULL, [Tag_StartupPriority] NVARCHAR(100) NULL, [Tag_SRMRecoveryType] NVARCHAR(100) NULL, [Tag_LicensingRestrictions] NVARCHAR(255) NULL ) Guest OS disk capacity / used columns are NULL for powered-off VMs and SRM placeholders. #> [CmdletBinding()] param( ) #region --- Setup --------------------------------------------------------------- [string] $OutputPath = 'C:\temp\VM_Trends\' [string] $ServerInstance = 'itdintsql22p1.nd.gov\INTSQL22P1' [string] $Database = 'ITD-Systems-Automation' [string] $Table = 'VMware_Trends_VM' [System.Management.Automation.PSCredential] $SqlCredential = $SqlCred #$Secret:sql_itdpsu1 [System.Management.Automation.PSCredential] $vCenterCredential = $PrvCred $RunDate = Get-Date $DateStamp = $RunDate.ToString('yyyyMMdd') $Timestamp = $RunDate.ToString('yyyy-MM-dd') if (-not (Test-Path -Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath | Out-Null } Start-Transcript -Path (Join-Path $OutputPath "VMMetadataReport_$DateStamp.log") -Append #endregion #region --- Build VMHost -> Cluster/Datacenter Lookups (avoids per-VM API calls) - Write-Verbose 'Building host-to-cluster and host-to-datacenter maps...' $HostClusterMap = @{} $HostDatacenterMap = @{} Get-Datacenter | ForEach-Object { $DatacenterName = $_.Name Get-VMHost -Location $_ | ForEach-Object { $HostDatacenterMap[$_.Name] = $DatacenterName } } Get-Cluster | ForEach-Object { $ClusterName = $_.Name Get-VMHost -Location $_ | ForEach-Object { $HostClusterMap[$_.Name] = $ClusterName } } #endregion #region --- Collect VM Data ----------------------------------------------------- Write-Verbose "Gathering VMs" # Include ALL VMs (SRM placeholders flagged via column, not excluded). # vCLS agent VMs are excluded -- they are vSphere internal and not customer workloads. $AllVMs = Get-VM | Where-Object { $_.Name -notlike 'vCLS*' } #--- Pre-fetch all tag assignments in one API call (avoids per-VM Get-TagAssignment) Write-Verbose 'Pre-fetching VM tag assignments...' $TagLookup = @{} Get-TagAssignment -Entity $AllVMs | ForEach-Object { $VMId = $_.Entity.Id $Cat = $_.Tag.Category.Name $TagName = $_.Tag.Name if (-not $TagLookup.ContainsKey($VMId)) { $TagLookup[$VMId] = @{} } if ($TagLookup[$VMId].ContainsKey($Cat)) { $TagLookup[$VMId][$Cat] += "; $TagName" } else { $TagLookup[$VMId][$Cat] = $TagName } } Write-Verbose "Processing $($AllVMs.Count) VMs..." $Results = foreach ($VM in $AllVMs) { $Ext = $VM.ExtensionData # single API object -- reuse for all fields #--- SRM placeholder detection $IsSRMPlaceholder = $Ext.Summary.Config.ManagedBy.Type -eq 'placeholderVm' #--- Cluster / Datacenter (null-safe: standalone hosts have no cluster entry) $ClusterName = $HostClusterMap[$VM.VMHost.Name] $DatacenterName = $HostDatacenterMap[$VM.VMHost.Name] #--- Tag assignments (pre-fetched; null when category not assigned to this VM) $VMTags = if ($TagLookup.ContainsKey($VM.Id)) { $TagLookup[$VM.Id] } else { @{} } #--- Storage platform parsed from datastore name convention: VMCLUSTER_LUN_PLATFORM_Desc # Segment 2 = storage platform identifier (e.g. FS92, A9K). # Cluster grouping uses the compute Cluster column -- no need to re-derive it here. $StoragePlatforms = foreach ($DSName in $Ext.Config.DatastoreUrl.Name) { $Segments = $DSName -split '_' if ($Segments.Count -ge 3) { $Segments[2] } } $StoragePlatform = ($StoragePlatforms | Sort-Object -Unique) -join '; ' #--- VMware Tools guest disk info # Populated only when Tools is running; null otherwise. $GuestDiskCapacityGB = $null $GuestDiskUsedGB = $null if ($Ext.Guest.Disk) { $TotalCapBytes = ($Ext.Guest.Disk | Measure-Object -Property Capacity -Sum).Sum $TotalFreeBytes = ($Ext.Guest.Disk | Measure-Object -Property FreeSpace -Sum).Sum $GuestDiskCapacityGB = [Math]::Round($TotalCapBytes / 1GB, 2) $GuestDiskUsedGB = [Math]::Round(($TotalCapBytes - $TotalFreeBytes) / 1GB, 2) } [PSCustomObject]@{ # --- Identity & grouping ReportDate = $Timestamp # for PowerBI time-series/trend axis VMName = $VM.Name Datacenter = $DatacenterName Cluster = $ClusterName PowerState = $VM.PowerState IsSRMPlaceholder = $IsSRMPlaceholder StoragePlatform = $StoragePlatform GuestOS = $Ext.Guest.GuestFullName # --- Compute vCPUs = $VM.NumCpu MemoryGB = $VM.MemoryGB # --- Datastore-level storage # ProvisionedSpaceGB : maximum the VM could consume (thin disks counted at max size) # UsedSpaceGB : bytes actually committed on datastores right now ProvisionedSpaceGB = [Math]::Round($VM.ProvisionedSpaceGB, 2) UsedSpaceGB = [Math]::Round($VM.UsedSpaceGB, 2) # --- Guest OS-level storage (from VMware Tools; null when Tools not running) # GuestDiskCapacityGB : sum of all volume capacities seen inside the guest # GuestDiskUsedGB : sum of space consumed across those volumes GuestDiskCapacityGB = $GuestDiskCapacityGB GuestDiskUsedGB = $GuestDiskUsedGB # --- VMware Tools # ToolsRunningStatus : guestToolsRunning | guestToolsNotRunning | guestToolsExecutingScripts # ToolsVersionStatus : guestToolsCurrent | guestToolsNeedUpgrade | guestToolsUnmanaged | guestToolsTooNew # ToolsVersion : numeric build version string reported by vCenter ToolsRunningStatus = $Ext.Guest.ToolsRunningStatus ToolsVersionStatus = $Ext.Guest.ToolsVersionStatus ToolsVersion = $Ext.Guest.ToolsVersion # --- vCenter Tags Tag_DRProtection = $VMTags['DR Protection'] Tag_AppName = $VMTags['AppName'] Tag_VRDatastores = $VMTags['VR Datastores'] Tag_VRRPO = $VMTags['VR RPO'] Tag_DTAP = $VMTags['DTAP'] Tag_StartupPriority = $VMTags['StartupPriority'] Tag_SRMRecoveryType = $VMTags['SRM Recovery Type'] Tag_LicensingRestrictions = $VMTags['LicensingRestrictions'] } } #endregion <#region --- Export CSV --------------------------------------------------------- $OutputFile = Join-Path $OutputPath "VMMetadata_$DateStamp.csv" $Results | Export-Csv -Path $OutputFile -NoTypeInformation Write-Verbose "Exported $($Results.Count) VM records to: $OutputFile" #endregion #> #region --- SQL Insert --------------------------------------------------------- # Build a typed DataTable so Write-SqlTableData knows each column's type # even when nullable columns contain null values. Type inference from raw # PSCustomObjects fails when the first row has a null in a numeric column. $DataTable = [System.Data.DataTable]::new() $ColDefs = [ordered]@{ ReportDate = [datetime] VMName = [string] Datacenter = [string] Cluster = [string] PowerState = [string] IsSRMPlaceholder = [bool] StoragePlatform = [string] GuestOS = [string] vCPUs = [int] MemoryGB = [decimal] ProvisionedSpaceGB = [decimal] UsedSpaceGB = [decimal] GuestDiskCapacityGB = [decimal] GuestDiskUsedGB = [decimal] ToolsRunningStatus = [string] ToolsVersionStatus = [string] ToolsVersion = [string] Tag_DRProtection = [string] Tag_AppName = [string] Tag_VRDatastores = [string] Tag_VRRPO = [string] Tag_DTAP = [string] Tag_StartupPriority = [string] Tag_SRMRecoveryType = [string] Tag_LicensingRestrictions = [string] } foreach ($Col in $ColDefs.GetEnumerator()) { $Column = [System.Data.DataColumn]::new($Col.Key, $Col.Value) $Column.AllowDBNull = $true [void]$DataTable.Columns.Add($Column) } foreach ($Row in $Results) { $DataRow = $DataTable.NewRow() foreach ($Col in $ColDefs.Keys) { $Val = $Row.$Col $DataRow[$Col] = if ($null -ne $Val) { $Val } else { [DBNull]::Value } } [void]$DataTable.Rows.Add($DataRow) } $SqlParams = @{ ServerInstance = $ServerInstance DatabaseName = $Database SchemaName = 'dbo' TableName = $Table Credential = $SqlCredential InputData = $DataTable } Write-SqlTableData @SqlParams Write-Verbose "Inserted $($DataTable.Rows.Count) VM records into [$Database].[dbo].[$Table]" #endregion #region --- Cleanup ------------------------------------------------------------- Stop-Transcript #endregion