Modules/Public/InventoryModules/Compute/VMOperationalData.ps1
|
<#
.Synopsis Operational Deep-Data for Azure Virtual Machines .DESCRIPTION This script provides deep operational data for Azure VMs including backup status, advisor recommendations count, update compliance, and lifecycle tag extraction. Supplement to VirtualMachine.ps1 — does not replace it. Excel Sheet Name: VM Operational Data .Link https://github.com/thisismydemo/azure-scout/Modules/Public/InventoryModules/Compute/VMOperationalData.ps1 .COMPONENT This powershell Module is part of Azure Scout (AZSC) .NOTES Version: 1.0.0 First Release Date: February 24, 2026 Authors: AzureScout Contributors #> <######## Default Parameters. Don't modify this ########> param($SCPath, $Sub, $Intag, $Resources, $Retirements, $Task, $File, $SmaResources, $TableStyle, $Unsupported) If ($Task -eq 'Processing') { $vms = $Resources | Where-Object { $_.TYPE -eq 'microsoft.compute/virtualmachines' } if ($vms) { # Pre-load backup items from Resources (ARG type for protected items) $backupItems = $Resources | Where-Object { $_.TYPE -like 'microsoft.recoveryservices/vaults/backupfabrics/protectioncontainers/protecteditems' } # Pre-load Advisor recommendations if present in Resources $advisorRecs = $Resources | Where-Object { $_.TYPE -eq 'microsoft.advisor/recommendations' } $tmp = foreach ($1 in $vms) { $ResUCount = 1 # AB#5671: empty $sub1 (subscription out of scope) is not $null, so `.Name` throws; # and an untagged VM's Resource Graph row OMITS `tags`, so both `$1.tags` and # `$null.psobject.properties` throw. The '0' sentinel only ever existed to make the # tag loop run once for an untagged resource -- but `'0'.Name` throws too, so an empty # tag object replaces it, emitting the identical [string]-cast empty Tag Name/Value. $sub1 = $SUB | Where-Object { $_.Id -eq $1.subscriptionId } # The else arm is $null, NOT '': with StrictMode off $sub1.Name on an unmatched ($null) # $sub1 evaluated to $null, and the ~110 collectors that still read $sub1.Name directly # emit $null here. '' was a silent behaviour change -- the declarative equivalence proof # caught it on 11 collectors, and it would have been invisible on the rest (AB#5659). $SubscriptionName = if ($sub1) { @($sub1)[0].Name } else { $null } $data = $1.PROPERTIES $RowTags = Get-AZSCSafeProperty -InputObject $1 -Path 'tags' $TagProps = if ($null -ne $RowTags) { $RowTags.psobject.properties } else { $null } $Tags = if (![string]::IsNullOrEmpty($TagProps)) { $TagProps } else { [pscustomobject]@{ Name = $null; Value = $null } } # ---- Extensions summary ---- # An extension id shorter than nine segments makes the fixed [8] index return $null # rather than throw once it is guarded on segment count. $vmExts = $Resources | Where-Object { $_.TYPE -eq 'microsoft.compute/virtualmachines/extensions' -and (Get-AZSCIdSegment -Id $_.id -Index 8) -eq $1.NAME } $extCount = if ($vmExts) { @($vmExts).Count } else { 0 } $extNames = if ($vmExts) { (@($vmExts) | ForEach-Object { Get-AZSCSafeProperty -InputObject $_ -Path 'PROPERTIES.type' }) -join ', ' } else { 'None' } # `$vmExts.PROPERTIES.publisher` is member enumeration through a collection whose # elements may omit `publisher` entirely -- an absent key on EVERY element makes the # enumeration yield nothing, which is what StrictMode reports as the missing property. $extPublishers = @(Get-AZSCSafeProperty -InputObject $vmExts -Path 'PROPERTIES.publisher' -Enumerate) $extTypes = @(Get-AZSCSafeProperty -InputObject $vmExts -Path 'PROPERTIES.type' -Enumerate) $hasAMA = if ($vmExts -and ($extPublishers -contains 'Microsoft.Azure.Monitor')) { 'Yes' } else { 'No' } $hasDefender = if ($vmExts -and ($extTypes -like '*MDE*' -or $extPublishers -like '*MicrosoftDefender*')) { 'Yes' } else { 'No' } # ---- Boot diagnostics ---- # `diagnosticsProfile` is ABSENT (not null) on any VM created without boot diagnostics, # which is the default -- so this was the very first line to throw here (AB#5671). $bootDiagEnabled = Get-AZSCSafeProperty -InputObject $data -Path 'diagnosticsProfile.bootDiagnostics.enabled' $bootDiagUri = Get-AZSCSafeProperty -InputObject $data -Path 'diagnosticsProfile.bootDiagnostics.storageUri' $PowerState = Get-AZSCSafeProperty -InputObject $data -Path 'extended.instanceView.powerState.displayStatus' $bootDiag = if ($bootDiagEnabled -eq $true) { 'Enabled' } else { 'Disabled' } $bootDiagSA = if ($bootDiagUri) { $bootDiagUri } else { 'Managed' } # ---- Backup status (cross-ref from pre-loaded backup items) ---- $backupItem = $backupItems | Where-Object { (Get-AZSCSafeProperty -InputObject $_ -Path 'PROPERTIES.sourceResourceId') -eq $1.id } $backupEnabled = if ($backupItem) { 'Yes' } else { 'No' } $lastBackupTime = if ($backupItem) { Get-AZSCSafeProperty -InputObject $backupItem -Path 'PROPERTIES.lastBackupTime' -Enumerate } else { 'N/A' } $backupVault = if ($backupItem) { Get-AZSCIdSegment -Id (Get-AZSCCollectedValue -InputObject $backupItem -Name 'id') -Index 8 } else { 'N/A' } $backupPolicy = if ($backupItem) { Get-AZSCSafeProperty -InputObject $backupItem -Path 'PROPERTIES.policyName' -Enumerate } else { 'N/A' } # ---- Advisor recommendations (cross-ref) ---- $vmAdvisor = $advisorRecs | Where-Object { (Get-AZSCSafeProperty -InputObject $_ -Path 'PROPERTIES.resourceMetadata.resourceId') -eq $1.id } $advisorCount = if ($vmAdvisor) { @($vmAdvisor).Count } else { 0 } $costAdvisor = if ($vmAdvisor) { @($vmAdvisor | Where-Object { (Get-AZSCSafeProperty -InputObject $_ -Path 'PROPERTIES.category') -eq 'Cost' }).Count } else { 0 } $secAdvisor = if ($vmAdvisor) { @($vmAdvisor | Where-Object { (Get-AZSCSafeProperty -InputObject $_ -Path 'PROPERTIES.category') -eq 'Security' }).Count } else { 0 } # ---- Update compliance via REST (optional, try/catch) ---- $pendingCritical = 'N/A' $pendingImportant = 'N/A' $lastPatchTime = 'N/A' try { $assessUri = "/subscriptions/$($1.subscriptionId)/resourceGroups/$($1.RESOURCEGROUP)/providers/Microsoft.Compute/virtualMachines/$($1.NAME)/assessPatches?api-version=2023-03-01" $assessResp = Invoke-AzRestMethod -Path $assessUri -Method POST -ErrorAction SilentlyContinue if ($assessResp.StatusCode -in 200, 202) { $assessData = $assessResp.Content | ConvertFrom-Json $pendingCritical = if ($assessData.criticalAndSecurityPatchCount) { $assessData.criticalAndSecurityPatchCount } else { 0 } $pendingImportant = if ($assessData.otherPatchCount) { $assessData.otherPatchCount } else { 0 } $lastPatchTime = if ($assessData.startDateTime) { ([datetime]$assessData.startDateTime).ToString('yyyy-MM-dd') } else { 'N/A' } } } catch {} # ---- Lifecycle tags ---- # Reading a specific tag off a resource that has no `tags` block at all, or has tags but # not this one, throws under StrictMode. Get-AZSCSafeProperty's lookup is already # case-insensitive, so the original Environment/environment pairs collapse to one read # each -- the same value, since the fall-back arm could only ever match what the first # arm already would have. $tagEnv = Get-AZSCSafeProperty -InputObject $RowTags -Path 'Environment' $tagOwner = Get-AZSCSafeProperty -InputObject $RowTags -Path 'Owner' $tagCostCenter = Get-AZSCSafeProperty -InputObject $RowTags -Path 'CostCenter' $tagExpiry = Get-AZSCSafeProperty -InputObject $RowTags -Path 'ExpirationDate' if (-not $tagExpiry) { $tagExpiry = Get-AZSCSafeProperty -InputObject $RowTags -Path 'Expiration' } if (-not $tagEnv) { $tagEnv = 'N/A' } if (-not $tagOwner) { $tagOwner = 'N/A' } if (-not $tagCostCenter) { $tagCostCenter = 'N/A' } if (-not $tagExpiry) { $tagExpiry = 'N/A' } foreach ($Tag in $Tags) { $obj = @{ 'ID' = $1.id; 'Subscription' = $SubscriptionName; 'Resource Group' = $1.RESOURCEGROUP; 'VM Name' = $1.NAME; 'Location' = $1.LOCATION; # `extended` comes from the instance-view expansion and is absent on a plain # Resource Graph row, so this whole chain throws without the expansion. 'Power State' = if ($PowerState) { $PowerState } else { 'N/A' }; 'Extensions Count' = $extCount; 'Extensions Installed' = $extNames; 'Azure Monitor Agent' = $hasAMA; 'Defender Extension' = $hasDefender; 'Boot Diagnostics' = $bootDiag; 'Boot Diag Storage' = $bootDiagSA; 'Backup Enabled' = $backupEnabled; 'Last Backup Time' = $lastBackupTime; 'Backup Vault' = $backupVault; 'Backup Policy' = $backupPolicy; 'Advisor Recs Total' = $advisorCount; 'Advisor Cost Recs' = $costAdvisor; 'Advisor Security Recs' = $secAdvisor; 'Pending Critical Patches' = $pendingCritical; 'Pending Other Patches' = $pendingImportant; 'Last Patch Assessment' = $lastPatchTime; 'Tag: Environment' = $tagEnv; 'Tag: Owner' = $tagOwner; 'Tag: Cost Center' = $tagCostCenter; 'Tag: Expiration Date' = $tagExpiry; 'Resource U' = $ResUCount; 'Tag Name' = [string]$Tag.Name; 'Tag Value' = [string]$Tag.Value; } $obj if ($ResUCount -eq 1) { $ResUCount = 0 } } } $tmp } } Else { if ($SmaResources) { $TableName = ('VMOperDataTable_' + (($SmaResources.'Resource U' | Measure-Object -Sum).Sum)) $Style = New-ExcelStyle -HorizontalAlignment Left -AutoSize -NumberFormat '0' $Cond = New-ConditionalText -ConditionalType ContainsText 'No' -ConditionalTextColor ([System.Drawing.Color]::FromArgb(255,165,0)) -BackgroundColor ([System.Drawing.Color]::White) $Cond2 = New-ConditionalText -ConditionalType ContainsText 'Disabled' -ConditionalTextColor ([System.Drawing.Color]::FromArgb(255,0,0)) -BackgroundColor ([System.Drawing.Color]::White) $Cond3 = New-ConditionalText -ConditionalType ContainsText 'Yes' -ConditionalTextColor ([System.Drawing.Color]::FromArgb(0,176,80)) -BackgroundColor ([System.Drawing.Color]::White) $Exc = New-Object System.Collections.Generic.List[System.Object] $Exc.Add('Subscription') $Exc.Add('Resource Group') $Exc.Add('VM Name') $Exc.Add('Location') $Exc.Add('Power State') $Exc.Add('Extensions Count') $Exc.Add('Extensions Installed') $Exc.Add('Azure Monitor Agent') $Exc.Add('Defender Extension') $Exc.Add('Boot Diagnostics') $Exc.Add('Boot Diag Storage') $Exc.Add('Backup Enabled') $Exc.Add('Last Backup Time') $Exc.Add('Backup Vault') $Exc.Add('Backup Policy') $Exc.Add('Advisor Recs Total') $Exc.Add('Advisor Cost Recs') $Exc.Add('Advisor Security Recs') $Exc.Add('Pending Critical Patches') $Exc.Add('Pending Other Patches') $Exc.Add('Last Patch Assessment') $Exc.Add('Tag: Environment') $Exc.Add('Tag: Owner') $Exc.Add('Tag: Cost Center') $Exc.Add('Tag: Expiration Date') $Exc.Add('Resource U') if ($InTag) { $Exc.Add('Tag Name'); $Exc.Add('Tag Value') } [PSCustomObject]$SmaResources | ForEach-Object { $_ } | Select-Object $Exc | Export-Excel -Path $File ` -WorksheetName 'VM Operational Data' ` -AutoSize -MaxAutoSizeRows 100 ` -ConditionalText $Cond, $Cond2, $Cond3 ` -TableName $TableName -TableStyle $TableStyle -Style $Style } } |