Skip to content

fix(azure): Legacy (EA) recommendations pair the normalized SKU with the un-normalized quantity #1540

Description

@cristim

Reviewed commit: be11bdcb5 (origin/main), from the 2026-07-28 full-repo review.

Severity: CRITICAL. Azure EA customers buy a fraction of the recommended capacity while the dashboard reports the recommendation as fully actioned.

Where

  • providers/azure/internal/recommendations/converter.go:146 and :151-152, via resolveLegacyResourceType at :228-230
  • Consumed by every service purchase body, e.g. providers/azure/services/compute/client.go:421-426

What

extractLegacy sets ResourceType from props.NormalizedSize (preferred first in the ladder) but sets Count from props.RecommendedQuantity.

Azure's armconsumption model carries two distinct quantity fields: RecommendedQuantity (units of the ACTUAL SKU, i.e. SKUProperties[SKUName]) and RecommendedQuantityNormalized (units of NormalizedSize). See armconsumption@v1.1.0/models.go:715-721. The code mixes the normalized size with the un-normalized count.

The Modern path (:243-247) prefers SKUName first and is therefore self-consistent. Only the Legacy/EA path is mismatched.

Failure scenario

EA subscription. Azure recommends 4 x Standard_D8s_v3 with NormalizedSize="Standard_D2s_v3", RecommendedQuantity=4, RecommendedQuantityNormalized=16.

The converter yields ResourceType="Standard_D2s_v3", Count=4. buildReservationBody sends sku.name="Standard_D2s_v3", quantity=4. The customer buys one quarter of the recommended capacity and keeps paying on-demand for the rest, while the dashboard reports the recommendation as fully actioned.

The error scales with the family's instance-flexibility ratio, up to 16x for a large-to-smallest normalization.

Scope (added after investigation, PR #1771)

Seven services consume this pairing through the one shared recommendations.Extract, not just the compute example above: compute, cache, cosmosdb, database, managedredis, search, synapse.

Count also feeds provider-agnostic logic in cmd/helpers.go, so the wrong count reached sizing math and duplicate suppression as well as the purchase body:

  • applySizing / ApplyCoverage scale by newCount / rec.Count
  • ApplyTargetCoverage anchors on rec.Count
  • --max-instances clamps on it
  • the duplicate checker compares existingCount >= rec.Count and subtracts

Verified it reaches a purchase, not only a display. The pair becomes sku.name and quantity on the calculatePrice and purchase request bodies, and Azure accepts it because both values are individually valid. The only quantity guard on the path is rec.Count <= 0.

Direction: under-buy — fails safe on commitment risk, unsafe on reported savings. Nobody is locked into capacity they cannot use, but EstimatedSavings is Azure's figure for the full recommended quantity and rides along unchanged, so the savings number shown to whoever approves the purchase is wrong. A decision-quality defect rather than a spend defect.

Checked negative: Modern/MCA's primary path is NOT affected

resolveModernResourceType prefers the top-level SKUName, which RecommendedQuantity counts, so the rung real MCA payloads take is self-consistent. Confirmed by running the converter, not assumed. Do not "fix" that rung.

(PR #1771 does pair Modern's fallback rung, which had the same mismatch when SKUName is absent, while leaving the SKUName rung byte-identical.)

Fix direction

In extractLegacy, either pair NormalizedSize with RecommendedQuantityNormalized, or make resolveLegacyResourceType prefer SKUProperties[SKUName] (matching the Modern ladder) so that RecommendedQuantity is in the right units. Assert the pairing in a converter test.

Note

Neither providers/azure nor providers/gcp is covered by CI (#1478), so nothing in this package has been vetted by go vet, golangci-lint, or the unit/integration suites.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions