Skip to content

Instantly share code, notes, and snippets.

@caseywatson
Last active October 11, 2024 18:09
Show Gist options
  • Save caseywatson/7b4d4972292c0bf3d83f3ad65b9d8262 to your computer and use it in GitHub Desktop.
Save caseywatson/7b4d4972292c0bf3d83f3ad65b9d8262 to your computer and use it in GitHub Desktop.
Workload-aware Azure Availability Zone VM analysis
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