Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

azcap — Azure VM capacity scanner

Microsoft does not publish per-region capacity. azcap gets as close as the platform allows, from two sources. The Resource SKUs API (az vm list-skus) shows which VM SKUs are restricted for your subscription in each region and zone; azcap reports that as a zone-adjusted % of SKUs restricted per region × family, with paired regions alongside. The Compute Recommender (preview) answers whether a specific allocation — N of a given SKU, zonal or regional — would place today, and distinguishes insufficient capacity from insufficient quota. Both are subscription-specific and neither is a published capacity figure or a reservation. Output is a self-contained HTML report plus CSVs.

Install

The easy way (any OS; pipx manages the virtual environment for you):

pipx install git+https://github.com/steverlabs/azcap.git
az login                       # or: az login --tenant <tenant-id>
azcap --help

Upgrade later with pipx upgrade azcap. If you don't have pipx: brew install pipx (macOS), py -m pip install --user pipx && py -m pipx ensurepath (Windows), or pip install --user pipx.

From a clone, for development:

git clone https://github.com/steverlabs/azcap.git && cd azcap
python3 -m venv .venv && source .venv/bin/activate     # Windows: py -m venv .venv ; .\.venv\Scripts\Activate.ps1
pip install -e .

Requires Python 3.10+ and the Azure CLI (for az login). Open the report with open out/report.html (macOS) or start out\report.html (Windows).

Auth uses the Azure CLI login by default (any DefaultAzureCredential source works). With --tenant, the CLI token is requested for that tenant and the script refuses to run if the subscription belongs to a different one. az account list -o table shows the tenant and subscription ids you're signed into.

Run

# scan a few regions, all VM families
azcap --regions eastus,eastus2,canadacentral,brazilsouth,saudiarabiaeast

# just the families you care about, with quota headroom
azcap --regions eastus,southeastasia --families Dv5,DSv5,Ev5,ESv5,NC,ND --include-quota

# name the tenant and subscription explicitly (results are subscription-specific;
# the report header is stamped with tenant id + subscription name so runs across
# different tenants stay distinguishable)
azcap --regions saudiarabiaeast --tenant <tenant-id> --subscription <sub-id>

# drop retired / legacy families that only add noise to the score
azcap --regions eastus,eastus2 --exclude-families "D,DS,G,GS,*Promo,LS,LSv2,Av2,A0_A7"

# only SKUs offered in availability zones (zone-resilient designs); non-AZ regions show n/a
azcap --regions eastus,eastus2,canadacentral --zonal

# skip the automatic paired-region scan
azcap --regions eastus --no-pairs

# diff against last week's snapshot
azcap --regions eastus --compare out-lastweek/raw.json

# give each VM family equal weight in the region figure (default is per-SKU weighting)
azcap --regions eastus,eastus2 --region-weighting family

# create shareable artifacts without tenant/subscription names or ids
azcap --regions eastus --redact-identifiers

# ask whether 12 x Standard_D8s_v5 would place across zones in each region right now
azcap --regions eastus2,brazilsouth --zonal --need Standard_D8s_v5:12

# judge each region against a workload profile; exit 3 if a requested region is blocked
azcap --regions eastus2 --profile fixtures/wave1.yaml --fail-on-blocked

# offline / demo with synthetic data
python fixtures/make_fixture.py   # generates fixtures/sample.json
azcap --regions eastus,brazilsouth,saudiarabiaeast --fixture fixtures/sample.json

Outputs land in --out (default out/):

file contents
report.html heatmap (region × family), paired regions, workload verdict, placement probes, zone-level restrictions, quota headroom, changes, filterable SKU table
summary.csv one row per region × family with counts, assessed count, and zone-adjusted % restricted
skus.csv one row per region × SKU with status, zones, reason codes
pairs.csv one row per requested region: pair, geography match, zone support, both-side scores, placement notes, verdicts and DR note
probes.csv with --need or --profile: one row per region × placement probe — score, result, quota detail, SKU/zone split
verdicts.csv with --profile: one row per region × profile VM — status, reason, family, vCPU need, quota, probe score, region verdict
quota.csv with --include-quota: used vs limit per family
raw.json normalized API snapshot — keep it, pass as --compare next run

How to read it

status source meaning
region_restricted NotAvailableForSubscription @ Location Microsoft won't allocate this SKU to this subscription in this region. Strongest restriction signal.
zone_restricted NotAvailableForSubscription @ Zone Allocatable in some zones only. Early warning; also breaks zone-redundant designs.
quota_blocked QuotaId A quota or subscription-eligibility restriction. Not assessed (excluded from % restricted); use --include-quota to inspect the reported family limit and usage.
available No restriction. Does not guarantee allocation at deploy time.

The figure is the zone-adjusted % of assessed SKUs restricted (0–100). Per region × family it is the mean restriction loss across assessed SKUs, where a region-restricted SKU counts 1.0 and a zone-restricted SKU counts blocked_zones / total_zones; × 100. Quota-blocked SKUs are not assessed. If none remain, the figure is n/a, not zero.

"Zone-adjusted" because a SKU blocked in one of three zones counts 0.33, not 1: the figure is lower than a plain count of SKUs carrying any restriction (the region-restricted and zone-restricted counts are shown alongside it). The region figure is weighted by assessed SKU count by default; families with more enumerated sizes have more influence. Use --region-weighting family to weight every assessed family equally (the figure is then an average of family rates, no longer a share of SKUs — the report header shows which weighting was used), and use --families / --sku to make the scope resemble the workload you intend to deploy.

Paired regions

By default each requested region's Microsoft-designated pair (from the Subscriptions API locations[].metadata.pairedRegion) is added to the scan and reported alongside it: region score vs. pair score, per-family comparison, and flags for cross-geography pairs (e.g. Brazil South ↔ South Central US), pairs without availability zones (e.g. Canada East), and families constrained on both sides. Regions Microsoft launched without a pair (most regions since ~2021, including Mexico Central and Saudi Arabia East) are reported as unpaired — DR for those is a region you pick, and the tool can't pick it for you; pass both regions explicitly and compare rows.

Placement probes

Restrictions say what Microsoft won't allocate; they say nothing about whether a specific request would place today. Microsoft's Compute Recommender does: given a SKU, a count, and optionally zones, it returns a placement score from 0 (worst) to 9 (best) for the best way it found to place that request, whether the full request was placed, and — if not — whether the shortfall is insufficient capacity or insufficient quota. That is the only capacity signal Azure exposes, and it is subscription-specific like everything else here. --need sends one such probe per region in the scan (pairs included) and puts the answer next to the restriction data: in the report (“Placement probes” section, plus “pair can place / neither side can place / quota blocks … on the pair” flags on the paired-regions table — quota and capacity verdicts are never merged into one note), the console, probes.csv, and raw.json.

The API is preview (skuMixPlacementScores, 2026-05-05-preview); when ARM moves to a newer version, pass it with --probe-api-version rather than waiting for a release. A probe that fails (404 in a region where the provider isn't registered, 400 for a SKU it doesn't know, 403, a non-JSON body) is recorded as an error row and reported on stderr; it never changes the restriction figures or the exit code. Scores describe a hypothetical allocation at the time of the run and expire (valid until is shown); they are not a reservation. For committed capacity use On-demand Capacity Reservations.

A probe needs quota headroom in the SKU's family before it can say anything about capacity. When the family's vCPU quota is at its limit the API answers with a quota verdict instead of a placement, shown as insufficient quota: standardDSv5Family. With --include-quota the tool checks each probe against the family's quota first (limit 0, or fewer free vCPUs than the request needs) and records insufficient quota: limit N, used U, need M vCPU without calling the API. On pay-as-you-go subscriptions whole generations (e.g. every v5 family) can have zero quota, so an "insufficient quota" row there means "request quota first", not "the region is full".

--need SKU:COUNT[,SKU:COUNT...] — each SKU:COUNT is a separate probe. With --zonal the probe asks for zonal placement across the zones that SKU is offered in (regions without zones fall back to a regional probe and are labelled regional); without it the probe is regional. --os Windows and --spot change the OS and priority sent; --need-mix sends every SKU in one request, ranked in the order given, and lets Azure pick the split.

# will 12 x Standard_D8s_v5 place across zones in eastus2 and brazilsouth (and their pairs)?
azcap --regions eastus2,brazilsouth --zonal --need Standard_D8s_v5:12

# 16 VMs from a ranked mix: prefer D8s_v5, fall back to E8s_v5, Windows, regional placement
azcap --regions eastus,westeurope --need Standard_D8s_v5:12,Standard_E8s_v5:4 --need-mix --os Windows

Console output, one line per region × probe:

Placement probes (regular VMs, Linux; Compute Recommender preview):
  eastus2                Standard_D8s_v5 x12   zonal   score 7  placed                 split 1:4 2:4 3:4
  brazilsouth            Standard_D8s_v5 x12   zonal   score 2  insufficient capacity  split 1:2 2:2 3:2
  canadacentral          Standard_D8s_v5 x12   zonal   score -  insufficient quota: standardDSv5Family

Workload profiles

--profile PATH turns the scan into a per-region verdict for a specific deployment: can this workload go here, and if not, what stops it. The profile is a small YAML (or JSON, same keys) file:

name: wave1                       # required; appears in labels
os: Linux                         # Linux | Windows (default Linux); sent with the placement probes
zonal: true                       # default false; true = the design must be zone-resilient
vms:                              # required, at least one
  - sku: Standard_D8s_v5          # exact SKU name
    count: 12                     # integer >= 1
  - sku: Standard_NC24ads_A100_v4
    count: 1
    optional: true                # default false; optional VMs are reported but never block

Unknown keys, missing fields, wrong types, and an empty vms are rejected up front. A SKU may appear twice only as one required and one optional entry (they share the family's quota; each gets its own verdict); listing it twice with the same optional flag is rejected. --profile implies --include-quota and a placement probe for each VM that is not already decided by steps 1–3 below (so it cannot be combined with --need); the profile's zonal controls the probe zones and the zone check, while --zonal still only narrows the restriction scan.

Every scanned region — pairs included — gets a verdict. For each VM the first of these that applies wins:

  1. not offered — the SKU is absent from the region's SKU list for this subscription.
  2. restricted — region-restricted; or, for a zonal profile, fewer than two usable zones (a SKU blocked in all but one zone cannot be placed zone-resiliently), including regions with no zones.
  3. quota — the family's vCPU quota is 0, or the free vCPUs are fewer than the profile needs. Needs are summed per family across the profile's required VMs before checking, since two SKUs in the same family share one quota; an optional VM is checked on top of that total. The reason shows both figures: family need 160 vCPU (this SKU 80).
  4. capacity — the placement probe could not place the request (InsufficientCapacity).
  5. deployable — the probe placed it. If the probe failed (preview API error) the VM is unknown: restrictions and quota allow it, but capacity could not be checked.

A region is deployable only when every required VM is deployable, blocked when any required VM hits 1–4 (the blocking VMs and reasons are listed), and unknown when nothing blocks but a probe failed. Optional VMs show their own status with an "(optional)" mark and never change the region verdict. Paired regions get a DR note: DR-ready, pair blocked: …, primary blocked: …, or both blocked …, using the same wording as the placement notes (quota is "quota blocks X", never "cannot place").

Exit codes, for gating a pipeline:

code meaning
0 finished; verdicts are informational unless --fail-on-blocked is set
1 could not run: authentication, Azure request, or invalid input/fixture/profile
2 usage error
3 --fail-on-blocked and a requested region is blocked (with --require-pair: or its paired region is)
# refuse to deploy wave 1 unless eastus2 can take it right now
azcap --regions eastus2 --profile wave1.yaml --fail-on-blocked || exit 1

# the same, but the DR pair must be deployable too
azcap --regions eastus2 --profile wave1.yaml --fail-on-blocked --require-pair || exit 1

Console output, one line per region (the offline demo: --fixture fixtures/sample.json --profile fixtures/wave1.yaml):

Workload "wave1" (zonal, Linux):
  eastus2                deployable  D8s_v5 x12 placed (score 9), E16s_v5 x4 placed (score 8), NC24ads_A100_v4 x1 placed (score 8) (optional)
                                     pair: DR-ready
  brazilsouth            BLOCKED     capacity: Standard_D8s_v5 (score 2, 6 of 12 placed); optional: NC24ads_A100_v4 restricted
                                     pair: both blocked — brazilsouth: cannot place Standard_D8s_v5; pair: quota blocks Standard_D8s_v5
  centralus              deployable  D8s_v5 x12 placed (score 7), E16s_v5 x4 placed (score 6), NC24ads_A100_v4 x1 restricted (optional)  (pair of eastus2)
  southcentralus         BLOCKED     quota: Standard_D8s_v5 (standardDSv5Family limit 0, family need 96 vCPU (this SKU 96))  (pair of brazilsouth)

A verdict is as good as its inputs: restrictions and quota are current, but a placement score is a hypothetical allocation at the time of the run, not a reservation. Use it to pick and gate, then reserve capacity if the deployment cannot tolerate AllocationFailed.

Two optional keys carry provenance when a profile was generated (see below); the verdict logic ignores them for now: source (where the profile came from and machine tallies) and disks ([{type: Premium_LRS, size: P30, count: 12}], the recommended managed disks).

Build a profile from Azure Migrate

If the workload was assessed in Azure Migrate, the assessment already names the target size for every machine. azcap profile turns it into a profile, either from the API or from the portal's Excel export:

# from the Azure Migrate API (needs Reader on the Migrate project; same az login as everything else)
azcap profile from-migrate --resource-group rg-migrate --project contoso-proj --group wave1 --assessment wave1-assess
azcap profile from-migrate --assessment-id /subscriptions/<sub>/resourceGroups/rg-migrate/providers/Microsoft.Migrate/assessmentProjects/contoso-proj/groups/wave1/assessments/wave1-assess

# from an export (Assessment > Export to Excel); needs the optional openpyxl extra
pip install 'azcap[migrate-xlsx]'
azcap profile from-migrate-xlsx wave1-export.xlsx

Common options: --out-profile PATH (default <name>.yaml in the current directory), --name, --conditional {optional,required,skip}, --headroom PCT; the API command also takes --subscription / --tenant.

Mapping rules:

  • Suitable machines become required VMs. ConditionallySuitable machines follow --conditional: optional (default) reports them without blocking, required counts them fully, skip leaves them out. NotSuitable and Unknown machines are excluded and tallied, with their suitabilityExplanation counted in the summary, as are machines with no recommended size.
  • Machines are grouped by recommended size into sku / count. When suitable and conditionally suitable machines share a size the profile gets two entries for that SKU, one required and one optional.
  • --headroom PCT adds ceil(count × PCT / 100) to each required count.
  • OS: one profile if every machine agrees; otherwise one profile per OS (<name>-linux.yaml, <name>-windows.yaml, each with os set). Machines whose OS Migrate reports as "other" go with the majority and are noted.
  • zonal is written as false because Migrate does not assess zone resilience — set it to true if the design must span zones.
  • source records the subscription, project, group, assessment, target region, sizing criterion, comfort factor, export time, and machine tallies; disks aggregates the recommended disk type and size using the API's names (Premium Premium_P30 × 12, types Premium / StandardSSD / Standard / Ultra / PremiumV2) so both importers write the same block.

The command prints machines by suitability, the resulting SKU counts, the disks, the assessment's target region, and the follow-up:

azcap --regions eastus2 --profile wave1-assess.yaml --fail-on-blocked

The Excel reader is calibrated against a current portal export (sheets Assessment_Summary, All_Assessed_Machines, All_Assessed_Disks, Assessment_Properties). It finds the sheets by name fragment and columns by header text — "readiness", "recommended size" / "azure vm size" / "target size", "machine" / "server", "operating system", and for disks "recommended disk" (type) / "disk size" (SKU). Readiness wording maps as Ready → Suitable, Ready With Conditions / Conditionally ready → ConditionallySuitable, Not Ready → NotSuitable, anything else → Unknown; disk types such as "Premium managed disks" are normalised to the API names. Assessment_Properties supplies the target location (East US 2eastus2, used in the printed follow-up command), sizing criterion, and comfort factor; Assessment_Summary supplies the subscription, project, group, and assessment names when present. If a required column is not found it lists the headers it saw — please report them; other export layouts may need the heuristics extended.

Caveats

  • Everything here is per subscription. Restrictions can differ from one subscription to another, including between EA, CSP, and pay-as-you-go offers. Run it under the subscription that will deploy.
  • Absence of a restriction is not a capacity guarantee. AllocationFailed / OverconstrainedAllocationRequest at deploy time can still happen. --need asks the Compute Recommender whether a specific request would place right now, which is closer, but still not a guarantee. For committed capacity use On-demand Capacity Reservations.
  • Region access restrictions (regions that require an access request to deploy at all) are not in this API; your account team or the Azure portal quota blade will show those.
  • The API reflects Microsoft's current allocation policy, which changes without notice. Take snapshots and diff them (--compare) rather than trusting a single run.
  • Snapshot comparison reports added, changed, and removed SKUs. It rejects a different tenant, subscription, or incomplete region scope unless --allow-incompatible-compare is supplied.
  • Reports normally contain the subscription name/id, tenant id, and optionally quota usage. Treat them as internal operational data or pass --redact-identifiers before sharing them.

Development

Run the offline checks without Azure credentials:

pip install -e '.[dev]'
ruff check .
ruff format --check .
pytest
python -m build

python fixtures/make_fixture.py regenerates fixtures/sample.json and fixtures/wave1.yaml; the fixture pins eastus2 / centralus / brazilsouth / southcentralus so a profile run shows every verdict. fixtures/migrate_sample.py holds a synthetic Azure Migrate assessment (API pages and an .xlsx export) used by the tests; run it to write fixtures/migrate_sample.xlsx and try azcap profile from-migrate-xlsx on it.

Extending

  • Add other resource types by relaxing the resource_type == virtualMachines filter in fetch_skus_live (disks, hostGroups, availabilitySets are also in the API).
  • Run it on a schedule and ship summary.csv to Log Analytics if you want a trend line.

About

Azure VM capacity scanner — SKU restrictions and placement probes per region, family, zone, and paired region

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages