Public/Get-AzureLocalClusterInventory.ps1
|
function Get-AzureLocalClusterInventory { <# .SYNOPSIS Gets an inventory of Azure Local clusters with their UpdateRing tag status. .DESCRIPTION Queries Azure Local (Azure Stack HCI) clusters and returns cluster details including the value of the 'UpdateRing' tag (or indicates if the tag doesn't exist). Supports multiple input methods: - All clusters via Azure Resource Graph (default) - Specific clusters by Resource ID - Specific clusters by name - Clusters matching an UpdateRing tag value The output can be exported to CSV for use with Excel to plan and populate UpdateRing tag values, then used as input for Set-AzureLocalClusterUpdateRingTag. .PARAMETER ClusterResourceIds An array of full Azure Resource IDs for the clusters to inventory. Example: "/subscriptions/xxx/resourceGroups/RG1/providers/Microsoft.AzureStackHCI/clusters/Cluster01" .PARAMETER ClusterNames An array of Azure Local cluster names to inventory. .PARAMETER ScopeByUpdateRingTag When specified, finds clusters by the 'UpdateRing' tag via Azure Resource Graph. Must be used together with -UpdateRingValue. .PARAMETER UpdateRingValue The value of the 'UpdateRing' tag to match when using -ScopeByUpdateRingTag. .PARAMETER ResourceGroupName The resource group containing the clusters (only used with -ClusterNames). .PARAMETER SubscriptionId Optional. Limit the query to a specific Azure subscription ID. If not specified, queries across all accessible subscriptions (default mode) or uses the current subscription (for -ClusterNames). .PARAMETER ExportPath Optional. Path to export the inventory. Supports CSV and JSON formats. Format is auto-detected from file extension (.csv or .json). CSV is useful for editing in Excel; JSON for CI/CD and API integrations. .EXAMPLE # Get inventory of all clusters across all subscriptions Get-AzureLocalClusterInventory .EXAMPLE # Get inventory for specific clusters by Resource ID Get-AzureLocalClusterInventory -ClusterResourceIds @("/subscriptions/xxx/resourceGroups/RG1/providers/Microsoft.AzureStackHCI/clusters/Cluster01") .EXAMPLE # Get inventory for clusters by name Get-AzureLocalClusterInventory -ClusterNames @("Cluster01", "Cluster02") -ResourceGroupName "MyRG" .EXAMPLE # Get inventory for clusters in a specific UpdateRing Get-AzureLocalClusterInventory -ScopeByUpdateRingTag -UpdateRingValue "Wave1" .EXAMPLE # Get inventory and export to CSV for editing in Excel Get-AzureLocalClusterInventory -ExportPath "C:\Temp\ClusterInventory.csv" .EXAMPLE # Get inventory and export to JSON for CI/CD pipelines Get-AzureLocalClusterInventory -ExportPath "C:\Temp\ClusterInventory.json" .EXAMPLE # Get inventory for a specific subscription Get-AzureLocalClusterInventory -SubscriptionId "12345678-1234-1234-1234-123456789012" .EXAMPLE # Pipeline workflow: Get inventory, edit CSV, then apply tags Get-AzureLocalClusterInventory -ExportPath "C:\Temp\Inventory.csv" # Edit the CSV in Excel to populate UpdateRing values Set-AzureLocalClusterUpdateRingTag -InputCsvPath "C:\Temp\Inventory.csv" .EXAMPLE # CI/CD pipeline: Export to CSV AND return objects for processing $inventory = Get-AzureLocalClusterInventory -ExportPath "C:\Temp\Inventory.csv" -PassThru Write-Host "Found $($inventory.Count) clusters" .NOTES Author: Neil Bird, Microsoft. #> [CmdletBinding(DefaultParameterSetName = 'All')] [OutputType([PSCustomObject[]])] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByResourceId')] [string[]]$ClusterResourceIds, [Parameter(Mandatory = $true, ParameterSetName = 'ByName')] [string[]]$ClusterNames, [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')] [switch]$ScopeByUpdateRingTag, [ValidatePattern('^[A-Za-z0-9_-]{1,64}$')] [Parameter(Mandatory = $true, ParameterSetName = 'ByTag')] [string]$UpdateRingValue, [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [string]$ResourceGroupName, [Parameter(Mandatory = $false, ParameterSetName = 'All')] [Parameter(Mandatory = $false, ParameterSetName = 'ByName')] [string]$SubscriptionId, [Parameter(Mandatory = $false)] [string]$ExportPath, [Parameter(Mandatory = $false)] [switch]$PassThru ) # Pre-flight: Validate export path is writable before expensive operations if ($ExportPath) { try { Test-ExportPathWritable -Path $ExportPath | Out-Null } catch { Write-Warning $_.Exception.Message; return } } Write-Log -Message "" -Level Info Write-Log -Message "========================================" -Level Header Write-Log -Message "Azure Local Cluster Inventory" -Level Header Write-Log -Message "========================================" -Level Header Write-Log -Message "" -Level Info # Verify Azure CLI is installed and logged in Test-AzCliAvailable | Out-Null try { $null = az account show 2>$null if ($LASTEXITCODE -ne 0) { throw "Azure CLI is not logged in. Please run 'az login' first." } Write-Log -Message "Azure CLI authentication verified" -Level Success } catch { Write-Log -Message "Azure CLI is not logged in. Please run 'az login' first." -Level Error return } # Ensure resource-graph extension is installed (needed for All and ByTag modes) if ($PSCmdlet.ParameterSetName -in @('All', 'ByTag')) { if (-not (Install-AzGraphExtension)) { Write-Log -Message "Failed to install Azure CLI 'resource-graph' extension. Please install manually: az extension add --name resource-graph" -Level Error return } } # Build cluster data based on parameter set $clusterData = @() if ($PSCmdlet.ParameterSetName -eq 'ByResourceId') { # Direct REST lookup for each Resource ID Write-Log -Message "Looking up $($ClusterResourceIds.Count) cluster(s) by Resource ID..." -Level Info $apiVer = $script:DefaultApiVersion foreach ($resourceId in $ClusterResourceIds) { $clusterName = ($resourceId -split '/')[-1] Write-Host " Checking: $clusterName..." -ForegroundColor Gray -NoNewline try { $uri = "https://management.azure.com${resourceId}?api-version=$apiVer" $clusterInfo = (Invoke-AzRestJson -Uri $uri).Data if ($LASTEXITCODE -eq 0 -and $clusterInfo) { $rgName = ($clusterInfo.id -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1 $subId = ($clusterInfo.id -split '/subscriptions/')[1] -split '/' | Select-Object -First 1 $clusterData += [PSCustomObject]@{ id = $clusterInfo.id name = $clusterInfo.name resourceGroup = $rgName subscriptionId = $subId tags = $clusterInfo.tags } Write-Host " Found" -ForegroundColor Green } else { Write-Host " Not Found" -ForegroundColor Red Write-Log -Message "Cluster not found: $resourceId" -Level Warning } } catch { Write-Host " Error" -ForegroundColor Red Write-Log -Message "Error looking up '$resourceId': $($_.Exception.Message)" -Level Warning } } } elseif ($PSCmdlet.ParameterSetName -eq 'ByName') { # Look up clusters by name if (-not $SubscriptionId) { $SubscriptionId = (az account show --query id -o tsv) } Write-Log -Message "Looking up $($ClusterNames.Count) cluster(s) by name..." -Level Info foreach ($name in $ClusterNames) { Write-Host " Checking: $name..." -ForegroundColor Gray -NoNewline $clusterInfo = Get-AzureLocalClusterInfo -ClusterName $name ` -ResourceGroupName $ResourceGroupName -SubscriptionId $SubscriptionId if ($clusterInfo) { $rgName = ($clusterInfo.id -split '/resourceGroups/')[1] -split '/' | Select-Object -First 1 $subId = ($clusterInfo.id -split '/subscriptions/')[1] -split '/' | Select-Object -First 1 $clusterData += [PSCustomObject]@{ id = $clusterInfo.id name = $clusterInfo.name resourceGroup = $rgName subscriptionId = $subId tags = $clusterInfo.tags } Write-Host " Found" -ForegroundColor Green } else { Write-Host " Not Found" -ForegroundColor Red Write-Log -Message "Cluster '$name' not found - skipping" -Level Warning } } } elseif ($PSCmdlet.ParameterSetName -eq 'ByTag') { # Query by UpdateRing tag via ARG Write-Log -Message "Querying Azure Resource Graph for clusters with tag 'UpdateRing' = '$UpdateRingValue'..." -Level Info $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | where tags['UpdateRing'] =~ '$($UpdateRingValue -replace "'", "''")' | project id, name, resourceGroup, subscriptionId, tags | order by name asc" try { $clusters = Invoke-AzResourceGraphQuery -Query $argQuery if (-not $clusters -or $clusters.Count -eq 0) { Write-Log -Message "No clusters found with tag 'UpdateRing' = '$UpdateRingValue'" -Level Warning return @() } Write-Log -Message "Found $($clusters.Count) cluster(s) matching tag criteria" -Level Success $clusterData = $clusters } catch { Write-Log -Message "Error querying Azure Resource Graph: $_" -Level Error return } } else { # Default: All clusters via ARG Write-Log -Message "Querying Azure Resource Graph for all Azure Local clusters..." -Level Info # Build Azure Resource Graph query - use single line to avoid escaping issues with az CLI $argQuery = "resources | where type =~ 'microsoft.azurestackhci/clusters' | project id, name, resourceGroup, subscriptionId, tags | order by name asc" try { if ($SubscriptionId) { Write-Log -Message " Filtering to subscription: $SubscriptionId" -Level Verbose $clusterData = Invoke-AzResourceGraphQuery -Query $argQuery -SubscriptionId $SubscriptionId } else { Write-Log -Message " Querying across all accessible subscriptions" -Level Verbose $clusterData = Invoke-AzResourceGraphQuery -Query $argQuery } if (-not $clusterData -or $clusterData.Count -eq 0) { Write-Log -Message "No Azure Local clusters found." -Level Warning return @() } } catch { Write-Log -Message "Error querying Azure Resource Graph: $($_.Exception.Message)" -Level Error return @() } } if ($clusterData.Count -eq 0) { Write-Log -Message "No clusters to inventory." -Level Warning return @() } try { # Get subscription names for better readability Write-Log -Message "Retrieving subscription details..." -Level Info $subscriptionMap = @{} $uniqueSubIds = $clusterData | Select-Object -ExpandProperty subscriptionId -Unique foreach ($subId in $uniqueSubIds) { try { $subInfo = az account show --subscription $subId 2>&1 | ConvertFrom-Json if ($LASTEXITCODE -eq 0 -and $subInfo.name) { $subscriptionMap[$subId] = $subInfo.name } else { $subscriptionMap[$subId] = "(Unable to retrieve name)" } } catch { $subscriptionMap[$subId] = "(Unable to retrieve name)" } } # Build inventory results $inventory = @() foreach ($cluster in $clusterData) { # Read tag values via container-shape-agnostic helper so both # [PSCustomObject] and [Hashtable] tag shapes are handled. # NOTE: Do NOT name this local 'updateRingValue' - PowerShell is # case-insensitive on variable names, so that would alias the # function's [ValidatePattern(...)] $UpdateRingValue parameter # and throw a validation error for any cluster missing the tag. $ringTagValue = Get-TagValue -Tags $cluster.tags -Name 'UpdateRing' $windowTagValue = Get-TagValue -Tags $cluster.tags -Name $script:UpdateWindowTagName $exclusionsTagValue = Get-TagValue -Tags $cluster.tags -Name $script:UpdateExclusionsTagName $sideloadedTagValue = Get-TagValue -Tags $cluster.tags -Name $script:UpdateSideloadedTagName $versionInProgressTagValue = Get-TagValue -Tags $cluster.tags -Name $script:UpdateVersionInProgressTagName $inventoryItem = [PSCustomObject]@{ ClusterName = $cluster.name ResourceGroup = $cluster.resourceGroup SubscriptionId = $cluster.subscriptionId SubscriptionName = $subscriptionMap[$cluster.subscriptionId] UpdateRing = if ($ringTagValue) { $ringTagValue } else { "" } HasUpdateRingTag = if ($ringTagValue) { "Yes" } else { "No" } UpdateWindow = if ($windowTagValue) { $windowTagValue } else { "" } UpdateExclusions = if ($exclusionsTagValue) { $exclusionsTagValue } else { "" } UpdateSideloaded = if ($sideloadedTagValue) { $sideloadedTagValue } else { "" } UpdateVersionInProgress = if ($versionInProgressTagValue) { $versionInProgressTagValue } else { "" } ResourceId = $cluster.id } $inventory += $inventoryItem } # Calculate summary statistics $clustersWithTag = @($inventory | Where-Object { $_.HasUpdateRingTag -eq "Yes" }).Count $clustersWithoutTag = $inventory.Count - $clustersWithTag $ringGroups = @($inventory | Where-Object { $_.UpdateRing -ne "" } | Group-Object -Property UpdateRing) # Export if path specified if ($ExportPath) { try { # Ensure directory exists $ExportPath = Resolve-SafeOutputPath -Path $ExportPath $exportDir = Split-Path -Path $ExportPath -Parent if ($exportDir -and -not (Test-Path -Path $exportDir)) { $null = New-Item -ItemType Directory -Path $exportDir -Force } # Determine export format from file extension $extension = [System.IO.Path]::GetExtension($ExportPath).ToLower() $exportData = $inventory | Select-Object ClusterName, ResourceGroup, SubscriptionId, SubscriptionName, UpdateRing, HasUpdateRingTag, UpdateWindow, UpdateExclusions, UpdateSideloaded, UpdateVersionInProgress, ResourceId switch ($extension) { '.json' { Write-Utf8NoBomFile -Path $ExportPath -Content ($exportData | ConvertTo-Json -Depth 10) Write-Log -Message "Inventory exported to JSON: $ExportPath" -Level Success } default { # Default to CSV for .csv or any other extension $exportData | ConvertTo-SafeCsvCollection | Export-Csv -Path $ExportPath -NoTypeInformation -Force Write-Log -Message "Inventory exported to CSV: $ExportPath" -Level Success } } } catch { Write-Log -Message "Failed to export inventory: $($_.Exception.Message)" -Level Error } } # Display summary at the end Write-Log -Message "" -Level Info Write-Log -Message "Inventory Summary:" -Level Header Write-Log -Message " Total Clusters: $($inventory.Count)" -Level Info Write-Log -Message " Clusters with UpdateRing tag: $clustersWithTag" -Level $(if ($clustersWithTag -gt 0) { "Success" } else { "Verbose" }) Write-Log -Message " Clusters without UpdateRing tag: $clustersWithoutTag" -Level $(if ($clustersWithoutTag -gt 0) { "Warning" } else { "Verbose" }) # Group by UpdateRing value if ($ringGroups.Count -gt 0) { Write-Log -Message "" -Level Info Write-Log -Message " UpdateRing Distribution:" -Level Info foreach ($group in $ringGroups | Sort-Object Name) { Write-Log -Message " $($group.Name): $($group.Count) cluster(s)" -Level Success } } # Show next steps if file was exported if ($ExportPath -and (Test-Path -Path $ExportPath)) { $extension = [System.IO.Path]::GetExtension($ExportPath).ToLower() Write-Log -Message "" -Level Info if ($extension -eq '.json') { Write-Log -Message "Next Steps (JSON export):" -Level Header Write-Log -Message " - Use the JSON file for CI/CD pipelines, API integrations, or CMDB systems" -Level Info Write-Log -Message " - To apply tags, export to CSV format instead" -Level Info } else { Write-Log -Message "Next Steps (CSV export):" -Level Header Write-Log -Message " 1. Open the CSV in Excel" -Level Info Write-Log -Message " 2. Populate the 'UpdateRing' column with values (e.g., 'Wave1', 'Wave2', 'Pilot')" -Level Info Write-Log -Message " 3. Save the CSV file" -Level Info Write-Log -Message " 4. Run: Set-AzureLocalClusterUpdateRingTag -InputCsvPath '$ExportPath'" -Level Info } } Write-Log -Message "" -Level Info # Return inventory if: no CSV export, OR PassThru is specified # This allows CI/CD pipelines to use -PassThru to get objects for processing if (-not $ExportPath -or $PassThru) { return $inventory } } catch { Write-Log -Message "Error querying Azure Resource Graph: $($_.Exception.Message)" -Level Error return @() } } |