Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Output is a set of CSVs plus one **self-contained interactive HTML dashboard**.

| Capability | Minimum role |
|---|---|
| SKU / zonal enablement, zone mapping, quota/usage, AKS & resource inventory, AKS scale-headroom, capacity reservations, network quota, App Service quota | **Reader** on the target subscriptions |
| SKU / zonal enablement, zone mapping, quota/usage, AKS & resource inventory, AKS scale-headroom, capacity reservations, network quota, App Service quota, storage quota | **Reader** on the target subscriptions |
| Quota groups (pooled quota) | **Reader** + **management-group read** |
| Spot placement score (allocation-likelihood) | **Reader** + **Compute Recommendations Role** (read-only `placementScores/generate/action`) |

Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ All notable changes to this project are documented here. The format is based on

### Added

- **Storage quota visibility** (`Get-StorageQuota.ps1`) — a read-only collector that reports the
per-subscription, per-region **storage-account count** quota (the data behind
`az storage account show-usage`, default limit 250) with used / limit / available / `PctUsed` and an
OK / NearLimit / AtLimit flag (threshold via `-NearLimitPct`, default 80). Optionally
(`-IncludeDiskCapacityInventory`) it adds an **informational** managed-disk capacity inventory —
total provisioned GiB by region and disk SKU, summed from Resource Graph — explicitly flagged
`IsQuota=False` / `Informational` with a blank limit, because Azure exposes no general
per-subscription managed-disk capacity quota usage API. Supports `-Location`, `-AllLocations` and the
standard subscription selectors. Reader-only. Resolves #14.
- **App Service quota visibility** (`Get-AppServiceQuota.ps1`) — a read-only collector that joins App
Service Plan inventory (Resource Graph) with the subscription/region
`Microsoft.Web/locations/{loc}/usages` and per-plan `Microsoft.Web/serverfarms/{name}/usages` APIs
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ analysis** — see the note below for the one optional write tool.
| **AKS (managed Kubernetes)** | ✅ | ✅ | ✅¹ | ✅ | ✅ | `Get-AksInventory`, `Get-AksScaleHeadroom` |
| **Networking (VNets, public IPs, NICs, LBs, NAT gateways)** | — | — | ✅ | — | ✅ | `Get-NetworkQuota` |
| **App Service (Microsoft.Web plans)** | — | — | ✅ | — | ✅ | `Get-AppServiceQuota` |
| **Storage (account count + disk inventory)** | — | — | ✅ | — | ✅ | `Get-StorageQuota` |
| **PostgreSQL / MySQL Flexible Servers** | ✅ | ✅ | — | ✅ | ✅ | `Get-FlexServerZones` |
| **Quota Groups (pooled vCPU quota)** | — | — | ✅ | — | ✅ | `Get-QuotaGroups`, `Get-QuotaGroupPlan` |
| **Any zone-pinned resource / region footprint** | — | ✅ | — | ✅ | ✅ | `Get-ZonalResourceInventory`, `Get-ResourceInventory`, `Get-RegionFootprint` |
Expand All @@ -60,6 +61,7 @@ analysis** — see the note below for the one optional write tool.
[capacity reservations](https://learn.microsoft.com/en-us/azure/virtual-machines/capacity-reservation-overview) ·
[networking limits](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#networking-limits) ·
[App Service limits](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#app-service-limits) ·
[Storage account limits](https://learn.microsoft.com/en-us/azure/storage/common/scalability-targets-standard-account) ·
[AKS reliability](https://learn.microsoft.com/en-us/azure/reliability/reliability-aks) ·
[PostgreSQL](https://learn.microsoft.com/en-us/azure/reliability/reliability-database-postgresql) /
[MySQL](https://learn.microsoft.com/en-us/azure/reliability/reliability-database-mysql) reliability.
Expand Down
1 change: 1 addition & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Most scripts accept:
| **AKS scale-headroom** — can every node pool reach its autoscaler `maxCount` within current family quota? | `Get-AksScaleHeadroom.ps1 -Location <r> [-SubscriptionCsv …]` | `aks-scale-headroom-detail-…csv` (per pool) + `…-rollup-…csv` (per family: incremental vCPUs needed vs quota, `CanFullyScale`, `ShortfallVCPUs`) |
| **Network quota** — where are VNets / public IPs / NICs / LBs near their regional limit? | `Get-NetworkQuota.ps1 [-Location <r>] [-AllLocations] [-SubscriptionCsv …]` | `network-quota-…csv` (per counter: used/limit/available/pctUsed, `NearLimit`/`AtLimit`, `IsUnbounded`, `PerResourceScope`) |
| **App Service quota** — App Service Plan inventory + `Microsoft.Web` regional/per-plan usage, plus tier scale-out ceilings | `Get-AppServiceQuota.ps1 [-Location <r>] [-AllLocations] [-SubscriptionCsv …]` | `appservice-quota-…csv` (`SubscriptionRegion`/`AppServicePlan`/`InventoryDerived` rows: used/limit/available/pctUsed, `NearLimit`/`AtLimit`, `PlanAtInstanceCeiling`, `IsTrueQuota`, `LimitBasis`) |
| **Storage quota** — storage-account count vs regional limit (+ optional disk-capacity inventory) | `Get-StorageQuota.ps1 [-Location <r>] [-AllLocations] [-IncludeDiskCapacityInventory] [-SubscriptionCsv …]` | `storage-quota-…csv` (StorageAccounts used/limit/available/pctUsed + `Flag`; informational `ManagedDiskProvisionedGiB` rows by SKU) |
| Review database (PostgreSQL/MySQL) zone + HA resilience | `Get-FlexServerZones.ps1 [-Location <r>]` | per-server SKU, tier, zone, HA mode, standby zone; single-zone flag |
| Sweep all zone-pinned resources for single-zone gaps | `Get-ZonalResourceInventory.ps1 [-Location <r>]` | every resource with a zone, by type, with SingleZone flag |
| **Complete overview: every resource type × sub × region** | `Get-ResourceInventory.ps1 [-SubscriptionIds …]` | `resource-inventory-…csv` (type, sub, region, count, zone-pinned count) |
Expand Down
14 changes: 14 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ compares each plan's current instance count to its documented ceiling. True API-
SKUs without a fixed ceiling (Consumption, Flex Consumption) are flagged `HasUnknownLimit` rather than
given a fabricated limit. The standing caveat applies: quota is not guaranteed physical capacity.

## Storage quota — account count, and why disk capacity is inventory not quota

The number of **storage accounts per subscription per region** is a real, adjustable Azure quota
([default 250](https://learn.microsoft.com/en-us/azure/storage/common/scalability-targets-standard-account))
and a common, silent blocker for storage-heavy or multi-tenant platforms. `Get-StorageQuota.ps1`
reads the regional storage usage API (behind `az storage account show-usage`) and reports
`StorageAccounts` with Used / Limit / Available / `PctUsed` and an OK / NearLimit / AtLimit flag.
Managed-disk *capacity* is a different story: Azure exposes **no general per-subscription managed-disk
capacity quota usage API** the way it does for compute vCPUs, so the optional
`-IncludeDiskCapacityInventory` rows sum provisioned `diskSizeGB` from Resource Graph and are labelled
`IsQuota=False` / `Informational` with a blank limit — they are an inventory aid, never a compliance
signal. (Disk SKU *availability* is `Microsoft.Compute/skus` metadata; per-disk size maxima are
documented per-resource constants.) Quota cleared is still not capacity guaranteed.

## Regional vs zonal enablement

A SKU's availability has **two independent signals**:
Expand Down
1 change: 1 addition & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ This page takes you from zero to a rendered capacity dashboard.
| AKS scale-headroom check | **Reader** | joins Resource Graph (node pools) + `Microsoft.Compute/skus` + `az vm list-usage` — all subscription reads |
| Network quota (regional usages) | **Reader** | `Microsoft.Network/locations/{loc}/usages` (via `az network list-usages`) |
| App Service quota (regional + per-plan usages) | **Reader** | Resource Graph (plans) + `Microsoft.Web/locations/{loc}/usages` + `Microsoft.Web/serverfarms/{name}/usages` |
| Storage quota (account count + disk inventory) | **Reader** | `az storage account show-usage` + (optional) Resource Graph disk sum |
| Activity-log error scan | **Reader** | for watching reconciles / allocation errors |
| Region footprint / multi-region compare | **Reader** | Resource Graph + per-region skus read |
| Quota group (shared pool) read | **Management-group Reader** | subscription Reader is *not* enough |
Expand Down
216 changes: 216 additions & 0 deletions scripts/Get-StorageQuota.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
<#
.SYNOPSIS
Storage quota visibility: regional storage-account count usage vs limit per subscription, with an
optional informational managed-disk capacity inventory. Exports a single CSV.

.DESCRIPTION
The number of storage accounts per subscription per region is a real, adjustable Azure quota
(default 250) and a common, silent blocker for storage-heavy or multi-tenant platforms. This
script surfaces that headroom as a pure READ-ONLY read of the regional Storage usage API (the data
behind `az storage account show-usage`).

Nothing is created or modified. Output is local CSV only.

Per subscription + region it reports the StorageAccounts counter with Used / Limit / Available /
PctUsed and an OK / NearLimit / AtLimit flag (threshold via -NearLimitPct, default 80%).

Optionally (-IncludeDiskCapacityInventory) it adds an INFORMATIONAL managed-disk capacity
inventory: total provisioned GiB by region (and disk SKU), summed from Resource Graph. This is
explicitly NOT a quota - Azure exposes no general per-subscription managed-disk capacity quota
usage API - so those rows carry IsQuota=False, blank Limit/Available/PctUsed and a Flag of
'Informational'. They are inventory, not a compliance signal.

IMPORTANT: quota headroom is NOT guaranteed physical capacity (see docs/concepts.md).

Access required: Reader on the target subscriptions. The disk inventory uses Resource Graph
(auto-installs the `resource-graph` az extension if missing).

.PARAMETER Location
Region(s) to evaluate (e.g. 'norwayeast'). Quota is region-specific. Default 'norwayeast'. Ignored
when -AllLocations is set.

.PARAMETER AllLocations
Evaluate every physical Azure region the signed-in identity can list, instead of -Location.

.PARAMETER SubscriptionIds
Optional explicit subscription id(s). Omit to scan every visible subscription.

.PARAMETER SubscriptionCsv
Optional CSV with a SubId column. Omit both to scan all visible subscriptions.

.PARAMETER NearLimitPct
Percent-of-limit threshold for the NearLimit flag. Default 80.

.PARAMETER IncludeDiskCapacityInventory
Also emit informational managed-disk provisioned-GiB rows (Resource Graph). Off by default to keep
the quota signal clean.

.PARAMETER OutPath
Output CSV path. Default ..\output\storage-quota-<region|all>-<date>.csv.

.EXAMPLE
.\Get-StorageQuota.ps1 -Location norwayeast

.EXAMPLE
.\Get-StorageQuota.ps1 -AllLocations -IncludeDiskCapacityInventory -SubscriptionCsv .\mysubs.csv
#>
[CmdletBinding()]
param(
[string[]] $Location = @('norwayeast'),
[switch] $AllLocations,
[string[]] $SubscriptionIds,
[string] $SubscriptionCsv,
[int] $NearLimitPct = 80,
[switch] $IncludeDiskCapacityInventory,
[string] $OutPath
)

. "$PSScriptRoot\Common.ps1"
Assert-AzLogin | Out-Null

if ($IncludeDiskCapacityInventory) {
az extension show --name resource-graph -o none 2>$null
if (-not $?) {
Write-Host "Installing az 'resource-graph' extension..." -ForegroundColor DarkGray
az extension add --name resource-graph -o none 2>$null
}
}

$subs = @(Resolve-Subscriptions -SubscriptionIds $SubscriptionIds -SubscriptionCsv $SubscriptionCsv)

$date = Get-Date -Format 'yyyyMMdd'
if (-not $OutPath) {
$outDir = Get-DefaultOutDir
$tag = if ($AllLocations) { 'all' } else { ($Location -join '-') }
$OutPath = Join-Path $outDir ("storage-quota-{0}-{1}.csv" -f $tag, $date)
}

$regions = @()
if ($AllLocations) {
$regions = @(az account list-locations --query "[?metadata.regionType=='Physical'].name" -o json 2>$null | ConvertFrom-Json | Sort-Object)
} else {
$regions = @($Location)
}
if (-not $regions -or $regions.Count -eq 0) { $regions = @('norwayeast') }

# Sum provisioned managed-disk GiB by region + disk SKU for one subscription (Resource Graph, paged).
function Get-DiskInventory {
param([string]$SubId, [string[]]$Regions, [bool]$AllRegions)
$clauses = @(
"resources",
"where type =~ 'microsoft.compute/disks'"
)
if (-not $AllRegions) {
$list = ($Regions | ForEach-Object { "'" + $_.ToLower() + "'" }) -join ','
$clauses += "where tolower(location) in ($list)"
}
$clauses += "extend diskSizeGB = tolong(properties.diskSizeGB), diskSku = tostring(sku.name)"
$clauses += "summarize Used = sum(diskSizeGB), DiskCount = count() by location, diskSku"
$query = $clauses -join ' | '
$cmd = @('graph','query','-q',$query,'--first','1000','--subscriptions',$SubId)
$rows = @(); $skip = 0
do {
$b = az @cmd --skip $skip -o json 2>$null | ConvertFrom-Json
if ($b.data) { $rows += $b.data }
$total = $b.total_records
$skip += 1000
} while ($b -and $b.data.Count -gt 0 -and $rows.Count -lt $total)
return ,$rows
}

Write-Host ("Evaluating storage quota across {0} subscription(s) x {1} region(s)..." -f $subs.Count, $regions.Count) -ForegroundColor Cyan

$rows = @()

foreach ($s in $subs) {
az account set --subscription $s.SubId 2>$null

# 1. Storage-account count quota per region.
foreach ($region in $regions) {
$usage = $null
try { $usage = az storage account show-usage --location $region -o json 2>$null | ConvertFrom-Json } catch { $usage = $null }
if (-not $usage) { continue }

foreach ($u in @($usage)) {
if ($u.name.value -ne 'StorageAccounts') { continue }
$used = [long]$u.currentValue
$limit = [long]$u.limit

$available = ''
$pctUsed = ''
$flag = 'Unknown'
if ($limit -gt 0) {
$available = $limit - $used
$pctUsed = [Math]::Round(($used / $limit) * 100, 1)
if ($used -ge $limit) { $flag = 'AtLimit' }
elseif ($pctUsed -ge $NearLimitPct) { $flag = 'NearLimit' }
else { $flag = 'OK' }
}

$rows += [pscustomobject][ordered]@{
Subscription = $s.Name
SubscriptionId = $s.SubId
Region = $region
Metric = 'StorageAccounts'
DiskSku = ''
Used = $used
Limit = $limit
Available = $available
PctUsed = $pctUsed
Unit = 'Count'
IsQuota = $true
Flag = $flag
Notes = 'Quota is not guaranteed capacity.'
}
}
}

# 2. Optional informational managed-disk capacity inventory.
if ($IncludeDiskCapacityInventory) {
$disks = Get-DiskInventory -SubId $s.SubId -Regions $regions -AllRegions ([bool]$AllLocations)
foreach ($d in $disks) {
$rows += [pscustomobject][ordered]@{
Subscription = $s.Name
SubscriptionId = $s.SubId
Region = $d.location
Metric = 'ManagedDiskProvisionedGiB'
DiskSku = $d.diskSku
Used = [long]$d.Used
Limit = ''
Available = ''
PctUsed = ''
Unit = 'GiB'
IsQuota = $false
Flag = 'Informational'
Notes = ("Inventory only ({0} disk(s)); no managed-disk capacity quota endpoint." -f $d.DiskCount)
}
}
}
}

if (-not $rows -or $rows.Count -eq 0) {
Write-Host "`nNo storage usage data returned for the scanned subscription(s)/region(s)." -ForegroundColor Yellow
return
}

$rows | Export-Csv $OutPath -NoTypeInformation -Encoding UTF8
Write-Host "`nExported $($rows.Count) storage row(s) -> $OutPath" -ForegroundColor Green

$walls = @($rows | Where-Object { $_.Flag -eq 'AtLimit' })
$near = @($rows | Where-Object { $_.Flag -eq 'NearLimit' })

Write-Host ("`nStorage-account quota - At limit: {0} Near limit (>= {1}%): {2}" -f $walls.Count, $NearLimitPct, $near.Count)

if ($walls.Count) {
Write-Host "`nAt storage-account limit:" -ForegroundColor Red
$walls | Sort-Object SubscriptionId, Region | ForEach-Object { " {0,-24} {1,-16} {2}/{3}" -f $_.Subscription, $_.Region, $_.Used, $_.Limit }
}
if ($near.Count) {
Write-Host "`nNear storage-account limit:" -ForegroundColor Yellow
$near | Sort-Object { [double]$_.PctUsed } -Descending | ForEach-Object { " {0,-24} {1,-16} {2}% ({3}/{4})" -f $_.Subscription, $_.Region, $_.PctUsed, $_.Used, $_.Limit }
}
if (-not $walls.Count -and -not $near.Count) {
Write-Host "`nNo storage-account counters at or near limit for the scanned scope." -ForegroundColor Green
}

Write-Host "`nReminder: quota headroom is not guaranteed physical capacity." -ForegroundColor DarkYellow