Last active
October 11, 2024 18:09
-
-
Save caseywatson/7b4d4972292c0bf3d83f3ad65b9d8262 to your computer and use it in GitHub Desktop.
Workload-aware Azure Availability Zone VM analysis
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
param( | |
[Parameter(Mandatory = $true)] | |
[string]$SubscriptionId, | |
[ValidateRange(2, 3)] | |
[int]$MinZones = 2, | |
[string]$ByTag, | |
[string]$ByNameRegexPattern, | |
[string]$ByKQL, | |
[switch]$ByResourceGroup, | |
[switch]$ByPrefix, | |
[string]$CsvOutputPath, | |
[switch]$Debugging | |
) | |
$InformationPreference = 'Continue' | |
if ($Debugging) { | |
$DebugPreference = 'Continue' | |
} | |
function Test-Parameter { | |
$valid = $true | |
if (-not $SubscriptionId) { | |
Write-Error "[-SubscriptionId] must be provided." | |
$valid = $false | |
} | |
$byParameterCount = ` | |
@($ByTag, $ByNameRegexPattern, $ByResourceGroup, $ByKQL, $ByPrefix ` | |
| Where-Object { $_ }).Count | |
$byTagDisplayNames = ` | |
"string: [-ByTag], " + ` | |
"string: [-ByNameRegexPattern], " + ` | |
"string: [-ByPrefix], " + ` | |
"switch: [-ByResourceGroup], " + ` | |
"or string: [-ByKQL]" | |
if ($byParameterCount -eq 0) { | |
Write-Error "At least one of the following parameters must be set: $byTagDisplayNames" | |
$valid = $false | |
} | |
elseif ($byParameterCount -gt 1) { | |
Write-Error "Only one of the following parameters can be set: $byTagDisplayNames" | |
$valid = false | |
} | |
if ($valid) { | |
Write-Information "Parameters are valid." | |
} | |
$valid | |
} | |
function Test-ModuleRequirement { | |
param( | |
[Parameter(Mandatory = $true)] | |
[string]$ModuleName, | |
[Parameter(Mandatory = $true)] | |
[version]$ModuleVersion | |
) | |
Write-Information "Checking that [$ModuleName] module version [$ModuleVersion] is installed..." | |
$modules = Get-Module -Name "$ModuleName" -ListAvailable -ErrorAction SilentlyContinue | |
if (-not $modules) { | |
Write-Warning "Module [$ModuleName] is not installed. Trying to install version [$ModuleVersion] or higher..." | |
Install-Module -Name "$ModuleName" -ModuleVersion "$ModuleVersion" -SkipPublisherCheck -AllowClobber -Force | |
Write-Information "Module [$ModuleName] installed successfully." | |
} | |
else { | |
$installedVersion = ($modules | Sort-Object Version -Descending | Select-Object -First 1).Version | |
if ($installedVersion -lt $ModuleVersion) { | |
Write-Warning "Installed version of [$ModuleName] is [$installedVersion], which is lower than the required version [$ModuleVersion]. Updating..." | |
Install-Module -Name "$ModuleName" -RequiredVersion "$ModuleVersion" -SkipPublisherCheck -AllowClobber -Force | |
Write-Information "Module [$ModuleName] updated to version [$ModuleVersion] or higher." | |
} | |
else { | |
Write-Information "Module [$ModuleName] version [$installedVersion] is installed." | |
} | |
} | |
$true | |
} | |
function Test-Requirement { | |
if ($(Test-ModuleRequirement -ModuleName "Az.Resources" -ModuleVersion "7.4") -and | |
$(Test-ModuleRequirement -ModuleName "Az.ResourceGraph" -ModuleVersion "1.0")) { | |
Write-Information "All module requirements met." | |
$true | |
} | |
else { | |
Write-Error "One or more module requirements not met." | |
$false | |
} | |
} | |
function Build-ZonalRegionQueryFilter() { | |
@" | |
where tolower(tostring(location)) in~ ( | |
"southafricanorth", | |
"eastasia", | |
"southeastasia", | |
"australiaeast", | |
"brazilsouth", | |
"canadacentral", | |
"chinanorth3", | |
"northeurope", | |
"westeurope", | |
"francecentral", | |
"germanywestcentral", | |
"centralindia", | |
"israelcentral", | |
"italynorth", | |
"japaneast", | |
"koreacentral", | |
"norwayeast", | |
"polandcentral", | |
"qatarcentral", | |
"swedencentral", | |
"switzerlandnorth", | |
"uksouth", | |
"southcentralus", | |
"westus2", | |
"westus3", | |
"centralus", | |
"eastus", | |
"eastus2" | |
) | |
"@ | |
} | |
function Build-GroupingQuery { | |
if ($ByPrefix) { | |
"resources " + ` | |
"| where type =~ 'microsoft.compute/virtualmachines' " + ` | |
"| $(Build-ZonalRegionQueryFilter)" + ` | |
"| where name matches regex `"^[a-zA-Z]+\\d+$`"" + ` | |
"| extend prefix = tostring(extract(`"^[a-zA-Z]+`", 0, name))" + ` | |
"| summarize by location, group = prefix" | |
} | |
else { | |
$query = "resources " + ` | |
"| where type =~ 'microsoft.compute/virtualmachines' " + ` | |
"| $(Build-ZonalRegionQueryFilter)" + ` | |
"| summarize by location, group = " | |
if ($ByTag) { | |
$query += "(tostring(tags['$ByTag']))" | |
} | |
elseif ($ByRegexPattern) { | |
$query += "(tostring(extract_all(@'(?i)$ByRegexPattern', name)))" | |
} | |
elseif ($ByResourceGroup) { | |
$query += "(resourceGroup)" | |
} | |
elseif ($ByKQL) { | |
$query += "$ByKQL" | |
} | |
$query | |
} | |
} | |
function Build-ZoneQuery { | |
param( | |
[Parameter(Mandatory = $true)] | |
[string]$Location, | |
[Parameter(Mandatory = $true)] | |
[string]$Group | |
) | |
$query = "resources " + ` | |
"| where type =~ 'microsoft.compute/virtualmachines' " + ` | |
"| $(Build-ZonalRegionQueryFilter)" + ` | |
"| where location == '$Location' " | |
if ($ByPrefix) { | |
$query += "| where name matches regex `"^$Group\\d+$`"" | |
} | |
elseif ($ByTag) { | |
$query += "| where tostring(tags['$ByTag']) == '$Group'" | |
} | |
elseif ($ByRegexPattern) { | |
$query += "| where tostring(extract_all(@'(?i)$Group', name)) == '$Group'" | |
} | |
elseif ($ByResourceGroup) { | |
$query += "| where resourceGroup == '$Group'" | |
} | |
elseif ($ByKQL) { | |
$query += "| where ($ByKQL) == '$Group'" | |
} | |
$query + "| summarize zoneCount = count() by zone = tostring(zones[0])" | |
} | |
function Get-Group { | |
$query = Build-GroupingQuery | |
Write-Debug "Executing query: [$query]" | |
Search-AzGraph ` | |
-Query $query ` | |
-Subscription $SubscriptionId | |
} | |
function Get-GroupZones { | |
param( | |
[Parameter(Mandatory = $true)] | |
[string]$Location, | |
[Parameter(Mandatory = $true)] | |
[string]$Group | |
) | |
$query = Build-ZoneQuery -Location $Location -Group $Group | |
Write-Debug "Executing query: [$query]" | |
Search-AzGraph ` | |
-Query $query ` | |
-Subscription $SubscriptionId | |
} | |
function Build-GroupZoneMetricsRollup() { | |
$groups = Get-Group | |
$groupZonesRollup = @() | |
if ($groups) { | |
foreach ($group in ($groups | Where-Object { $_.group })) { | |
$groupName = $group.group | |
$groupLocation = $group.location | |
$groupZones = Get-GroupZones -Location $groupLocation -Group $groupName | |
$groupZoneMetrics = @{ | |
Group = $groupName | |
Location = $groupLocation | |
Zone1 = 0 | |
Zone2 = 0 | |
Zone3 = 0 | |
NoZone = 0 | |
} | |
foreach ($groupZone in $groupZones) { | |
switch ($groupZone.zone) { | |
"1" { $groupZoneMetrics.Zone1 = $groupZone.zoneCount } | |
"2" { $groupZoneMetrics.Zone2 = $groupZone.zoneCount } | |
"3" { $groupZoneMetrics.Zone3 = $groupZone.zoneCount } | |
default { $groupZoneMetrics.NoZone = $groupZone.zoneCount } | |
} | |
} | |
$groupZonesRollup += $groupZoneMetrics | |
} | |
} | |
else { | |
Write-Warning "No groups found." | |
} | |
$groupZonesRollup | |
} | |
function Export-GroupZoneReportCsv() { | |
param( | |
[Parameter(Mandatory = $true)] | |
[array]$Rollup, | |
[Parameter(Mandatory = $true)] | |
[string]$CsvOutputPath | |
) | |
$Rollup | Export-Csv -Path $CsvOutputPath | |
} | |
function Build-GroupZoneAnalysis() { | |
param( | |
[Parameter(Mandatory = $true)] | |
[array]$Rollup | |
) | |
$analysis = @() | |
foreach ($groupMetrics in $Rollup) { | |
$groupAnalysis = @{ | |
Group = $groupMetrics.Group | |
Location = $groupMetrics.Location | |
Zone1 = $groupMetrics.Zone1 | |
Zone2 = $groupMetrics.Zone2 | |
Zone3 = $groupMetrics.Zone3 | |
NoZone = $groupMetrics.NoZone | |
SpreadScore = 0 | |
TotalVms = 0 | |
TotalZones = 0 | |
} | |
if ($groupMetrics.Zone1) { | |
$groupAnalysis.TotalVms += $groupMetrics.Zone1 | |
$groupAnalysis.TotalZones++ | |
} | |
if ($groupMetrics.Zone2) { | |
$groupAnalysis.TotalVms += $groupMetrics.Zone2 | |
$groupAnalysis.TotalZones++ | |
} | |
if ($groupMetrics.Zone3) { | |
$groupAnalysis.TotalVms += $groupMetrics.Zone3 | |
$groupAnalysis.TotalZones++ | |
} | |
$groupAnalysis.TotalVms += $groupMetrics.NoZone | |
$maxDiff = 0 | |
$totalDiff = 0 | |
if ($groupMetrics.Zone1 -and $groupMetrics.Zone2) { | |
$diff = [Math]::Abs($groupMetrics.Zone1 - $groupMetrics.Zone2) | |
$maxDiff = if ($diff -gt $maxDiff) { $diff } else { $maxDiff } | |
$totalDiff += $diff | |
} | |
if ($groupMetrics.Zone2 -and $groupMetrics.Zone3) { | |
$diff = [Math]::Abs($groupMetrics.Zone2 - $groupMetrics.Zone3) | |
$maxDiff = if ($diff -gt $maxDiff) { $diff } else { $maxDiff } | |
$totalDiff += $diff | |
} | |
if ($groupMetrics.Zone3 -and $groupMetrics.Zone1) { | |
$diff = [Math]::Abs($groupMetrics.Zone3 - $groupMetrics.Zone1) | |
$maxDiff = if ($diff -gt $maxDiff) { $diff } else { $maxDiff } | |
$totalDiff += $diff | |
} | |
$avgDiff = ($totalDiff / 3) | |
if ($groupAnalysis.TotalZones -lt 2) { | |
$groupAnalysis.SpreadScore = 0 | |
} | |
else { | |
$groupAnalysis.SpreadScore = if ($maxDiff -gt 0) { ((1 - ($avgDiff / $maxDiff)) * 100) } else { 100 } | |
} | |
$analysis += [pscustomobject]$groupAnalysis | |
} | |
$analysis | |
} | |
function Connect-AzureSubscription { | |
Write-Information "Running locally..." | |
Connect-AzAccount ` | |
-WarningAction Ignore ` | |
-InformationAction Ignore | Out-Null | |
Set-AzContext ` | |
-Subscription "$SubscriptionId" ` | |
-WarningAction Ignore ` | |
-InformationAction Ignore | Out-Null | |
} | |
if (Test-Requirement) { | |
Connect-AzureSubscription | |
if (Test-Parameter) { | |
$rollup = Build-GroupZoneMetricsRollup | |
$table = @() | |
$analysis = Build-GroupZoneAnalysis -Rollup $rollup | |
foreach ($groupAnalysis in $analysis) { | |
$alert = ($groupAnalysis.SpreadScore -lt 80) | |
$spreadScore = "$("{0:N2}%" -f $groupAnalysis.SpreadScore)" | |
if ($alert) { | |
$spreadScore = "⚠️ $spreadScore" | |
} | |
else { | |
$spreadScore = " $spreadScore" | |
} | |
$groupRow = [pscustomobject]@{ | |
Group = $groupAnalysis.Group | |
Location = $groupAnalysis.Location | |
AZ1 = $groupAnalysis.Zone1 | |
AZ2 = $groupAnalysis.Zone2 | |
AZ3 = $groupAnalysis.Zone3 | |
NoZone = $groupAnalysis.NoZone | |
Total = $groupAnalysis.TotalVms | |
Score = $spreadScore | |
} | |
$table += $groupRow | |
} | |
if ($CsvOutputPath) { | |
Export-GroupZoneReportCsv ` | |
-Rollup $rollup ` | |
-CsvOutputPath $CsvOutputPath | |
} | |
$table | Format-Table -Property Group, Location, AZ1, AZ2, AZ3, NoZone, Total, Score -AutoSize | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment