checks/arm/vm-reservation-coverage.ps1
|
# Cost — reserved VM instance coverage. # # Four independent sources, each optional. The maths lives in _reservations.ps1 (pure, # self-checked by ps/tests/reservation-coverage.ps1); this file only fetches and reports. # # 1. running inventory Resource Graph Azure Reader # 2. size-flexibility ratios Microsoft.Capacity/catalogs Azure Reader # 3. owned reservations Microsoft.Capacity/reservationOrders RESERVATIONS READER (tenant) # 4. purchase candidates Microsoft.Consumption/reservationRecommendations Azure Reader # # The maths is deliberately SIMPLE: reserved units vs running units per region x flexibility # group. A reservation is a POOL any machine in that family can draw on, so a per-family # shortfall is the honest answer. Per-instance billing attribution was tried and removed — # it was harder to trust, not easier (user's call, 2026-08-08). # # Source 3 is the only one that can see a reservation you already own sitting idle, and it # needs a tenant-scope role that Azure Reader on subscriptions does NOT grant. When it is # unavailable the check says which questions it could not answer — it must never report a # clean result on the strength of the other three. . (Join-Path $PSScriptRoot '_reservations.ps1') $gaps = @() # human-readable "what I could not read" $instances = @(); $ratios = @{}; $reservations = @(); $recs = @() $invOk = $false; $resOk = $false; $recOk = $false; $ratioOk = $false # --- 1. running inventory ------------------------------------------------------------- # TWO queries, not one union. ARG rejected the union form with "Failed to resolve column # named 'cnt'": a column created by `project`/`extend` cannot be referenced by a later # `where` in ARG's KQL subset (same family as the projected-`title` trap already documented). # Hence: filter with the FULL expression before projecting, and concatenate in PowerShell. # Flexible-orchestration scale sets are excluded because their members already appear as # individual microsoft.compute/virtualmachines rows — counting both doubles the footprint. $kqlVm = @' resources | where type =~ 'microsoft.compute/virtualmachines' | where isnotempty(tostring(properties.hardwareProfile.vmSize)) | project id, name, size=tostring(properties.hardwareProfile.vmSize), power=tostring(properties.extended.instanceView.powerState.code), prio=tostring(properties.priority), loc=location, sub=subscriptionId '@ $kqlSs = @' resources | where type =~ 'microsoft.compute/virtualmachinescalesets' | where tostring(properties.orchestrationMode) != 'Flexible' | where isnotempty(tostring(sku.name)) and toint(sku.capacity) > 0 | project id, name, size=tostring(sku.name), cap=toint(sku.capacity), prio=tostring(properties.virtualMachineProfile.priority), loc=location, sub=subscriptionId '@ try { # assign-then-iterate: Search-Graph returns the whole array as ONE object (`, $all`), # so piping it directly member-enumerates rows into column arrays. $vmRows = Search-Graph $kqlVm $instances = @($vmRows | ForEach-Object { [pscustomobject]@{ Id = $_.id; Name = $_.name; Size = $_.size; Power = $_.power Loc = "$($_.loc)".ToLower(); Sub = $_.sub; Count = 1; Kind = 'vm' Prio = $_.prio } }) $invOk = $true } catch { $gaps += "running VM inventory unreadable ($_)" } try { $ssRows = Search-Graph $kqlSs $instances += @($ssRows | ForEach-Object { [pscustomobject]@{ Id = $_.id; Name = $_.name; Size = $_.size; Power = 'PowerState/running' Loc = "$($_.loc)".ToLower(); Sub = $_.sub; Count = $_.cap; Kind = 'scaleset' Prio = $_.prio } }) } catch { $gaps += "scale-set inventory unreadable, so their instances are not counted ($_)" } # Spot VMs and the A/G series can never draw a reservation discount, so leaving them in the # running total invents a shortfall no purchase could ever close. Dropped here, and counted # so the omission is stated rather than silent. $ineligible = @($instances | Where-Object { -not (Test-ReservationEligible $_.Size $_.Prio) }) if ($ineligible.Count) { $instances = @($instances | Where-Object { Test-ReservationEligible $_.Size $_.Prio }) $spotN = @($ineligible | Where-Object { "$($_.Prio)" -match '(?i)spot' }).Count $gaps += ("$($ineligible.Count) instance(s) excluded as ineligible for any reservation" + $(if ($spotN) { " ($spotN Spot" + $(if ($ineligible.Count -gt $spotN) { ', rest A/G-series' } else { '' }) + ')' } else { ' (A/G-series)' })) } # --- 2. instance size flexibility ratios ---------------------------------------------- # Per region, from the reservation catalog. The published CSV at aka.ms/isf is removed on # 2026-08-30, so the API is the only durable source. Ratios are used RAW: Microsoft does # not normalise them to 1 for the smallest SKU in a group, but both sides of every # comparison use the same scale, so normalising would change no answer. $subs = @() try { $subs = @(Get-Subs) } catch { $gaps += "could not list subscriptions ($_)" } $regions = @($instances | ForEach-Object { $_.Loc } | Where-Object { $_ } | Sort-Object -Unique) # The catalog pages with $skip/$take and returns NO nextLink, so Invoke-Arm's paging does # not apply — ask for one big page explicitly or you silently get the first slice only. # The reservedResourceType spelling also differs between references (the reservations enum # says VirtualMachines, the Az.Reservations example says VirtualMachine), so try both: the # wrong value yields a 400/empty, which is what left this table empty on the first live run. $catErr = @{} foreach ($region in $regions) { $got = $false foreach ($s in @($subs | Select-Object -First 3)) { foreach ($rrt in 'VirtualMachines', 'VirtualMachine') { try { # Retry once: the whole family comparison is withheld if this returns nothing, so a # single transient 429/timeout silently degrades the entire check. $cat = $null foreach ($attempt in 1..2) { try { $cat = Invoke-Arm -ApiVersion '2022-11-01' ` -Path "/subscriptions/$($s.Id)/providers/Microsoft.Capacity/catalogs?reservedResourceType=$rrt&location=$region&`$take=2000" break } catch { if ($attempt -eq 2) { throw } ; Start-Sleep -Seconds 3 } } foreach ($c in @($cat)) { if (-not $c.name) { continue } $grp = $null; $ratio = $null foreach ($p in @($c.skuProperties)) { # The property names differ by api-version; accept both spellings. switch -Regex ("$($p.name)") { '(?i)^(ReservationsAutofitGroup|InstanceSizeFlexibilityGroup)$' { $grp = "$($p.value)" } '(?i)^(ReservationsAutofitRatio|InstanceSizeFlexibilityRatio)$' { $ratio = "$($p.value)" } } } if (-not $grp -or -not $ratio) { continue } if (-not $ratios.ContainsKey($region)) { $ratios[$region] = @{} } $ratios[$region]["$($c.name)".ToLower()] = @{ Group = $grp; Ratio = [double]$ratio } } # A 200 with no usable rows is NOT success — claiming "ratios from the catalog" # while silently comparing exact sizes is the kind of quiet lie this check exists # to stop. Only a region that actually yielded ratios counts. if ($ratios.ContainsKey($region) -and $ratios[$region].Count) { $got = $true; $ratioOk = $true; break } } catch { $catErr[$region] = "$_"; continue } } if ($got) { break } } if (-not $got) { $why = if ($catErr.ContainsKey($region)) { ": $($catErr[$region])" } else { ': the catalog returned no rows carrying autofit properties' } $gaps += "no size-flexibility ratios for $region (coverage comparison withheld there)$why" } } # --- 3. owned reservations ------------------------------------------------------------ $DEAD = @('Expired', 'Cancelled', 'Split', 'Merged', 'Failed') # Audit trail of every reservation the API returned, kept and skipped alike. "It says none # reserved but I own one" is unanswerable without this, and the reasons for skipping (wrong # resource type, dead provisioning state, archived) are all invisible from the outside. $seenRes = @(); $skipRes = @() # TWO ways to enumerate reservations, because they do not always agree: # Reservation_ListAll -> one tenant-wide call, "the reservations the user has access to" # the reservationOrders walk -> orders, then each order's reservations # ListAll is the primary (simpler, and the portal's own view); the order walk is the fallback # and a cross-check. Both counts are reported, because "the portal shows a reservation this # check never saw" is only answerable if the check says which list it used and how big it was. # ⚠️ THERE ARE TWO AUTHORIZATION METHODS FOR RESERVATIONS, AND THEY HAVE DIFFERENT APIS. # Per MS Learn ("Permissions to view and manage Azure reservations"): access comes from either # BILLING ADMIN roles or reservation RBAC roles, and the portal exposes them in two different # blades — RBAC users go to Home > Reservations, billing admins go to Cost Management + Billing # > Billing scopes > Products + services > "Reservations + Hybrid Benefit", which shows the # COMPLETE list for the enrollment/billing profile. # # Those blades are backed by different providers: # Microsoft.Capacity/reservationOrders|reservations <- honours reservation RBAC roles only # Microsoft.Billing/billingAccounts/{ba}/reservations <- honours EA/MCA BILLING roles # # An EA Enterprise Administrator (read-only) or MCA billing-profile Reader therefore sees every # reservation in the portal while Microsoft.Capacity returns 403 for the same reservation — # billing roles are not Azure RBAC, never appear in IAM, and need no PIM activation. That is # exactly what happened on one customer tenant: 26 visible in the portal, 7 via Capacity, and a specific # reservation order returning 403. So query BOTH and take the union. $rawRes = @(); $srcUsed = @(); $orderWalkCount = $null; $listAllCount = $null; $billingCount = $null $byKeyRes = @{} function Add-Res($rows, $label) { $n = 0 foreach ($x in @($rows)) { if (-not $x -or -not $x.properties) { continue } # ids differ by provider, so key on orderId/reservationId, not the whole path $k = if ("$($x.id)" -match '(?i)reservationOrders/([^/]+)/reservations/([^/]+)') { "$($matches[1])/$($matches[2])".ToLower() } else { "$($x.id)".ToLower() } if (-not $byKeyRes.ContainsKey($k)) { $byKeyRes[$k] = $x; $n++ } } if ($n) { $script:srcUsed += "$label(+$n)" } return $n } try { # ⚠️ NEVER @(Invoke-Arm ...) — the helper returns `, $all`, so wrapping it in @() nests the # whole result set into ONE element. 46 reservations collapsed to a single object whose # fields member-enumerated into parallel arrays, and only the order walk's 7 survived. # Assign, then use. (Same trap as the _lib.ps1 enumeration contract.) $la = Invoke-Arm -Path '/providers/Microsoft.Capacity/reservations?$take=200' -ApiVersion '2022-11-01' $listAllCount = @($la | Where-Object { $_ -and $_.properties }).Count [void](Add-Res $la 'Capacity_ListAll') } catch { $gaps += "Capacity reservation ListAll unreadable ($_)" } try { $orders = Invoke-Arm -Path '/providers/Microsoft.Capacity/reservationOrders' -ApiVersion '2022-11-01' $walk = @() foreach ($o in @($orders)) { if (-not $o.name) { continue } try { $w1 = Invoke-Arm -Path "/providers/Microsoft.Capacity/reservationOrders/$($o.name)/reservations" -ApiVersion '2022-11-01' foreach ($w in $w1) { $walk += $w } } catch { $gaps += "reservations of order $($o.name) unreadable ($_)" } } $orderWalkCount = @($walk | Where-Object { $_ -and $_.properties }).Count [void](Add-Res $walk 'Capacity_orderWalk') } catch { $gaps += "Capacity reservationOrders unreadable ($_)" } # The billing path — the one a billing admin's portal view actually uses. try { $bas = Invoke-Arm -Path '/providers/Microsoft.Billing/billingAccounts' -ApiVersion '2024-04-01' $bn = 0 foreach ($ba in @($bas)) { if (-not $ba.name) { continue } try { $br = Invoke-Arm -Path "/providers/Microsoft.Billing/billingAccounts/$($ba.name)/reservations" -ApiVersion '2024-04-01' if ($null -eq $br) { $br = @() } $bn += @($br | Where-Object { $_ -and $_.properties }).Count [void](Add-Res $br "Billing[$($ba.name)]") } catch { $gaps += "billing-account reservations unreadable for $($ba.name) ($_)" } } $billingCount = $bn } catch { $gaps += "billing accounts unreadable, so billing-role-visible reservations are missed ($_)" } $rawRes = @($byKeyRes.Values) if ($rawRes.Count) { $resOk = $true } else { $gaps += ('no reservations readable by ANY route - needs either Reservations Reader at tenant scope ' + '(Azure RBAC) or an EA/MCA billing-reader role') } $srcUsed = $(if ($srcUsed.Count) { $srcUsed -join ' + ' } else { 'none' }) foreach ($r in $rawRes) { $p = $r.properties $rlbl = "$(if ($p.displayName) { $p.displayName } else { $r.name }) [$($r.sku.name) x$($p.quantity) $($r.location) $($p.provisioningState)]" $seenRes += $rlbl if ("$($p.reservedResourceType)" -ne 'VirtualMachines') { $skipRes += "$rlbl -> not a VM reservation (type=$($p.reservedResourceType))"; continue } if ("$($p.provisioningState)" -in $DEAD) { $skipRes += "$rlbl -> provisioningState=$($p.provisioningState)"; continue } if ("$($p.archived)" -eq 'True') { $skipRes += "$rlbl -> archived"; continue } $isf = if ($p.reservedResourceProperties -and $p.reservedResourceProperties.instanceFlexibility) { "$($p.reservedResourceProperties.instanceFlexibility)" } else { "$($p.instanceFlexibility)" } $util = $null; $grain = $null foreach ($want in 30, 7, 1) { $a = @($p.utilization.aggregates | Where-Object { $_ -and [int]$_.grain -eq $want }) if ($a.Count) { $util = [double]$a[0].value; $grain = $want; break } } $scopes = @($p.appliedScopes) if (-not $scopes.Count -and $p.appliedScopeProperties) { $scopes = @($p.appliedScopeProperties | ForEach-Object { $_.subscriptionId } | Where-Object { $_ }) } $reservations += [pscustomobject]@{ Id = $r.id Name = $(if ($p.displayName) { "$($p.displayName)" } else { "$($r.name)" }) Sku = "$($r.sku.name)"; Loc = "$($r.location)".ToLower(); Quantity = [double]$p.quantity Term = "$($p.term)"; Expiry = $(if ($p.expiryDateTime) { $p.expiryDateTime } else { $p.expiryDate }) ScopeType = "$($p.appliedScopeType)"; Scopes = $scopes Isf = $isf; Renew = $p.renew; Util = $util; UtilGrain = $grain } } # --- 3c. savings plans (detect only) --------------------------------------------------- # An Azure savings plan for compute is NOT a reservation: different provider # (Microsoft.BillingBenefits), no per-family SKU, it discounts eligible compute spend # broadly. This check cannot model one — but a family covered by a savings plan would # otherwise be reported as "nothing is reserved here", which is actively misleading. So: # detect them and say so. Doubly relevant since the 2026-07-01 RI retirement pushes # customers onto savings plans for exactly these families. $savingsPlans = @(); $spOk = $false try { $sp = Invoke-Arm -Path '/providers/Microsoft.BillingBenefits/savingsPlanOrders' -ApiVersion '2022-11-01' $spOk = $true foreach ($o in @($sp)) { $q = $o.properties if (-not $q) { continue } $savingsPlans += "$(if ($q.displayName) { $q.displayName } else { $o.name }) [$($q.term) $($q.billingPlan)]" } } catch { $gaps += "savings plans unreadable, so a family covered by one may read as unreserved ($_)" } # --- 4. purchase candidates ----------------------------------------------------------- # The server applies defaults of scope=Single, resourceType=VirtualMachines and # lookBackPeriod=Last7Days when no filter is given, so the filter is ALWAYS explicit here. # Never re-filter resourceType client-side: it is absent from legacy rows, and dropping on # a missing value is what made this check silently report nothing at all. $unwrap = { param($x) if ($null -eq $x) { $null } elseif ($x -is [pscustomobject]) { $x.value } else { $x } } foreach ($s in $subs) { foreach ($scope in 'Single', 'Shared') { foreach ($look in 'Last60Days', 'Last30Days', 'Last7Days') { $f = "properties/scope eq '$scope' AND properties/resourceType eq 'VirtualMachines' AND properties/lookBackPeriod eq '$look'" $page = $null try { $page = Invoke-Arm -ApiVersion '2024-08-01' ` -Path ("/subscriptions/$($s.Id)/providers/Microsoft.Consumption/reservationRecommendations?`$filter=" + [uri]::EscapeDataString($f)) } catch { continue } $page = @($page | Where-Object { $_ -and $_.properties }) if (-not $page.Count) { continue } # nothing for this window; try a shorter one $recOk = $true foreach ($r in $page) { $p = $r.properties $sku = $(if ($p.skuName) { "$($p.skuName)" } else { "$($r.sku)" }) $loc = $(if ($p.location) { "$($p.location)".ToLower() } else { "$($r.location)".ToLower() }) # Free second source of flexibility ratios: every recommendation row carries # the group and ratio for its own SKU. It only covers recommended sizes, so it # cannot replace the catalog, but it fills gaps when the catalog is unreadable. if ($p.instanceFlexibilityGroup -and $p.instanceFlexibilityRatio -and $loc -and $sku) { if (-not $ratios.ContainsKey($loc)) { $ratios[$loc] = @{} } if (-not $ratios[$loc].ContainsKey("$sku".ToLower())) { $ratios[$loc]["$sku".ToLower()] = @{ Group = "$($p.instanceFlexibilityGroup)" Ratio = [double]$p.instanceFlexibilityRatio } } } $recs += [pscustomobject]@{ Sku = $sku; Loc = $loc Sub = "$($p.subscriptionId)"; Quantity = $p.recommendedQuantity Term = "$($p.term)"; Scope = "$($p.scope)"; LookBack = $look Savings = & $unwrap $p.netSavings Currency = $(if ($p.netSavings -is [pscustomobject]) { "$($p.netSavings.currency)" } else { $null }) } } break # longest window with data wins - 7 days is noise if a machine was off for three of them } } } # Nothing at all was readable -> loud skip, never a silent clean. if (-not ($invOk -or $resOk -or $recOk)) { return [pscustomobject]@{ Name = 'Reserved VM instance coverage' Status = 'skip' Detail = ('Could not read any of: Resource Graph inventory, Microsoft.Capacity reservations, ' + 'Microsoft.Consumption recommendations. ' + ($gaps -join '; ')) } } # Inventory that failed OR came back empty cannot support a coverage comparison. Passing # that fact down is what stops "5 of 5 units idle" being asserted against a reservation # Azure itself reports at 100%. $inventoryUsable = ($invOk -and @($instances).Count -gt 0) # --- collapse recommendations to one row per (sku, region, scope), carrying both terms -- $byKey = @{} foreach ($r in $recs) { $k = "$($r.Sku)|$($r.Loc)|$($r.Scope)".ToLower() if (-not $byKey.ContainsKey($k)) { $byKey[$k] = [pscustomobject]@{ Sku = $r.Sku; Loc = $r.Loc; Sub = $r.Sub; Quantity = $r.Quantity Scope = $r.Scope; LookBack = $r.LookBack; Currency = $r.Currency Savings1Y = $null; Savings3Y = $null } } if ("$($r.Term)" -eq 'P3Y') { $byKey[$k].Savings3Y = $r.Savings } else { $byKey[$k].Savings1Y = $r.Savings } if ([double]$r.Quantity -gt [double]$byKey[$k].Quantity) { $byKey[$k].Quantity = $r.Quantity } } # Is anything actually running there NOW? A recommendation is built from a 60-day lookback, # so a burst that finished a month ago still generates one — and a reservation for a region # you have since left is worse than useless. Two grains, because they answer different # questions: REGION presence is robust (no VMs there at all = not a live opportunity, demote # it), whereas exact-SKU presence is only an annotation — with size flexibility a D2s_v5 # recommendation is still relevant while you run D4s_v5 in that group. $regionHasVms = @{}; $skuRunning = @{} foreach ($i in $instances) { if (Test-BillablePower $i.Power) { $regionHasVms["$($i.Loc)".ToLower()] = $true $skuRunning["$($i.Loc)|$($i.Size)".ToLower()] = $true } } foreach ($v in $byKey.Values) { $v | Add-Member -NotePropertyName RegionActive ` -NotePropertyValue $(if ($inventoryUsable) { $regionHasVms.ContainsKey("$($v.Loc)".ToLower()) } else { $null }) -Force $v | Add-Member -NotePropertyName RunningNow ` -NotePropertyValue $(if ($inventoryUsable) { $skuRunning.ContainsKey("$($v.Loc)|$($v.Sku)".ToLower()) } else { $null }) -Force } $groups = @(Get-CoverageGroups $instances $reservations $ratios) # Only recommend buying where nothing is reserved for that size group yet. Where a # reservation already exists, the shortfall is reported above as units-beyond-the- # reservation, with real numbers instead of a generic "buy more". # NOT $covered — that name already holds the per-INSTANCE billing coverage set from 3b. # Reusing it here silently overwrote it, so every machine looked uncovered while the billing # data was perfect. Distinct concepts get distinct names. $reservedGroups = @{} foreach ($g in $groups) { if ($g.ReservedUnits -gt 0) { $reservedGroups["$($g.Region)|$($g.Group)".ToLower()] = $true } } $candidates = @($byKey.Values | Where-Object { $isf = Get-SizeIsf $ratios $_.Loc $_.Sku -not $reservedGroups.ContainsKey("$($_.Loc)|$($isf.Group)".ToLower()) }) $items = @(Get-ReservationFindings -Groups $groups -Recommendations $candidates -InventoryOk $inventoryUsable -SavingsPlansKnown $spOk) # ⚠️ Reservations are TENANT-level resources with their own RBAC that does NOT inherit from # subscriptions, so an identity can hold read on some reservation orders and not others — and # the ones it cannot see are simply absent, with no error anywhere. That silently inflates # every shortfall. Reservation_ListAll ("what this user can see") returning fewer than the # order walk is the tell. Verified live on a customer tenant 2026-08-08: ListAll=1, walk=7, and a # reservation order the consultant could see in the portal returned 403 to this identity. if ($null -eq $billingCount -or ($null -ne $listAllCount -and $null -ne $orderWalkCount -and $listAllCount -lt $orderWalkCount)) { $items += [pscustomobject]@{ Title = 'Reservation visibility is incomplete - shortfalls may be overstated' Detail = ("Azure's tenant-wide reservation list returned $listAllCount reservation(s) for this " + "sign-in, while enumerating reservation orders found $orderWalkCount. Reservations are " + 'tenant-level resources with their own RBAC that does not inherit from subscriptions, so ' + 'any reservation whose order this identity is not on is invisible here - it simply does ' + 'not appear, with no error. Every "short on reservations" and "no reservation for" finding ' + 'above assumes the list is complete, so treat them as an upper bound until this is fixed. ' + 'There are TWO authorization methods with TWO different APIs: reservation RBAC roles ' + '(Microsoft.Capacity, portal Home > Reservations) and EA/MCA BILLING roles ' + '(Microsoft.Billing/billingAccounts/../reservations, portal Cost Management + Billing > ' + 'Billing scopes > Products + services > Reservations + Hybrid Benefit). This run queried ' + 'both and took the union. Fix whichever failed: grant Reservations Reader at TENANT scope ' + 'for the RBAC route, or an EA Enterprise Administrator (read-only) / MCA billing profile ' + 'Reader role for the billing route.') Severity = 'medium' ResourceId = $null } } # --- degradation is a finding, never silence ------------------------------------------ # Withholding a comparison must itself be reported, or it is just silence with extra steps. # Two ways to lose it: no inventory at all, or no size-flexibility ratios for a region that # HAS reservations (Get-ReservationFindings withholds that group's comparisons per `Degraded`). $degradedGroups = @($groups | Where-Object { $_.Degraded -and $_.ReservedUnits -gt 0 }) if ($reservations.Count -and (-not $inventoryUsable -or $degradedGroups.Count)) { $why = if (-not $inventoryUsable) { 'The Resource Graph VM inventory ' + $(if ($invOk) { 'came back empty' } else { 'could not be read' }) + ', so idle and over-run capacity could only be judged from the utilization Azure reports, ' + 'applied-scope and flexibility-group problems were not assessed at all, and purchase ' + 'recommendations could not be checked against the regions you actually run in.' } else { 'Not compared: ' + (@($degradedGroups | ForEach-Object { "$($_.Group) in $($_.Region)" } | Sort-Object -Unique) -join '; ') + ' - the catalog has no flexibility ratio for ' + (@($degradedGroups | ForEach-Object { $_.MissingRatio } | Sort-Object -Unique | ForEach-Object { $_ -replace '(?i)^Standard_', '' }) -join ', ') + '. Every other family was compared normally. Older series often carry no ratio, so there ' + 'may be nothing to fix.' } $items += [pscustomobject]@{ Title = 'Reservation coverage not compared against running VMs' Detail = $why Severity = 'low' ResourceId = $null } } if (-not $resOk) { $items += [pscustomobject]@{ Title = 'Reservation utilization not assessed (missing Reservations Reader)' Detail = ('Could not read Microsoft.Capacity/reservationOrders, so this run cannot say whether an ' + 'existing reservation is idle, scoped to the wrong subscription, has instance size ' + 'flexibility switched off, or is about to expire. Grant Reservations Reader at tenant ' + 'scope (Azure portal > Home > Reservations > Role assignment) and re-run.') Severity = 'low' ResourceId = $null } } # Evidence surfaces on BOTH pass and fail rows; the returned Name does not, so the # always-visible summary has to live here. # Count INSTANCES, not rows: a uniform scale-set row carries sku.capacity instances. $vmCount = [int](@($instances | Where-Object { Test-BillablePower $_.Power }) | Measure-Object -Property Count -Sum).Sum $deadCnt = [int](@($instances | Where-Object { -not (Test-BillablePower $_.Power) }) | Measure-Object -Property Count -Sum).Sum $resUnits = [double](@($groups) | Measure-Object -Property ReservedUnits -Sum).Sum $runUnits = [double](@($groups) | Measure-Object -Property RunningUnits -Sum).Sum # Any source that failed gets ONE always-visible item with a stable title. Routing gaps # through some other finding's detail has now hidden a real error three times running: the # ARG query failure, the catalog failure, and the reservationDetails failure all reached the # user as silence. A check that cannot say what it failed to read cannot be trusted. $health = @("inventory=$(@($instances).Count) eligible instance(s)", [string]("VM reservations used=$(@($reservations).Count); unique reservations found=$($rawRes.Count) via $srcUsed" + " [Capacity ListAll=$(if ($null -ne $listAllCount) { $listAllCount } else { 'ERR' })," + " Capacity orderWalk=$(if ($null -ne $orderWalkCount) { $orderWalkCount } else { 'ERR' })," + " Billing accounts=$(if ($null -ne $billingCount) { $billingCount } else { 'ERR' })]"), "flexibility ratios=$(if ($ratioOk) { 'yes' } else { 'NO - comparison withheld' })", "families compared=$(@($groups | Where-Object { -not $_.Degraded }).Count)", # The purchase advisory is only emitted when something is worth buying, so this # line is the only place the "Azure suggested N, none of them actionable" case is # recorded at all. Without it, suppressing that item would be a silent cap. "purchase recommendations returned=$(@($candidates).Count)", ("reservations seen from the API: " + $(if ($seenRes.Count) { [string]($seenRes -join ', ') } else { 'none' }))) if ($skipRes.Count) { $health += [string]('SKIPPED: ' + ($skipRes -join '; ')) } $health += [string]("savings plans: " + $(if ($spOk) { $(if ($savingsPlans.Count) { ($savingsPlans -join ', ') + ' - NOT counted above; they discount compute spend broadly, so a family shown as unreserved may already be covered by one' } else { 'none' }) } else { 'COULD NOT BE READ' })) # Always emitted, stable title: a permanent audit line saying what each source returned. # THREE separate real bugs reached the user as silence before this existed (an invalid ARG # query, an empty catalog, and a malformed OData filter) — each time the check just quietly # used worse data. The cost of one low-severity row is trivial against that. $items += [pscustomobject]@{ Title = 'Reservation check: source diagnostics' Detail = (($health -join '; ') + $(if ($gaps.Count) { ' | ISSUES: ' + ($gaps -join ' | ') } else { '' })) Severity = 'low' ResourceId = $null } # Evidence is prefixed onto EVERY failing item by Add-CheckFindings, so a portfolio-wide # summary here would repeat down the whole list while saying nothing about the row it sits on. # Each finding below is written to stand alone, so the summary is only worth showing on the # PASS row — which is exactly the case where there are no findings to carry it. $evidence = $null if (-not $items.Count) { $evidence = ("$(@($reservations).Count) VM reservation(s) covering $(Format-Units $resUnits) units, " + "$vmCount running instance(s) needing $(Format-Units $runUnits)" + $(if ($deadCnt) { ", $deadCnt deallocated" } else { '' })) } [pscustomobject]@{ Name = 'Reserved VM instance coverage' Severity = 'medium' Evidence = $evidence Fix = ('Match reserved units to steady-state running units per region and size-flexibility group: ' + 'buy where you are short, and for idle units switch the reservation to Shared scope, turn ' + 'instance size flexibility on, or exchange it for the size/region you actually run.') Items = $items } |