Skip to content

fix(azure): carry the source reservation's applied scope through an RI exchange - #209

Merged
cristim merged 2 commits into
mainfrom
fix/azure-exchange-carry-source-scope
Oct 5, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/azure-exchange-carry-source-scope

Conversation

@cristim

@cristim cristim commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

What

An Azure RI exchange no longer buys the replacement reservation with AppliedScopeType = Shared by default. The source reservation's applied scope is carried from the tenant listing through CalculateExchange into every reservationsToPurchase entry.

  • ExchangeableReservation gains AppliedScopeType (armreservations.AppliedScopeType) and AppliedScopes, populated by ListExchangeableReservations from Properties.AppliedScopeType / Properties.AppliedScopes.
  • CalculateExchange refuses, before calling Azure:
    • a source with no reported scope (applied_scope_type is required; pass the reservation as returned by ListExchangeableReservations)
    • a scope type outside the SDK enum (for example ManagementGroup, which this SDK/API version cannot express on a purchase)
    • Single without exactly one applied scope (Azure's MissingAppliedScopesForSingle / InvalidSingleAppliedScopesCount)
    • every refusal wraps the exported sentinel compute.ErrUnsupportedAppliedScope (%w), so callers can match it with errors.Is
    • sources whose scopes differ (type, or for Single the scope set, compared case-insensitively and order-free; scopes on Shared sources are ignored), because one purchase cannot keep two scopes
  • buildCalculateExchangeRequest sends sources[0]'s scope type on every target, and its single scope only when the type is Single (appliedScopes stays nil, so it is omitted from the JSON, for Shared). The hard-coded AppliedScopeTypeShared fallback is gone.
  • ExchangeTarget.AppliedScopeType is removed. It could not express the subscription list a Single scope needs, and its nil value was the Shared fallback. Nothing in this repo or the platform sets it (checked cloud-commitments-platform at HEAD: internal/api/handler_ri_exchange.go:661 builds ExchangeTarget without it).

Why

Exchanging a reservation Single-scoped to prod for chargeback returned a Shared reservation, so other subscriptions took the discount and prod paid on-demand, and nothing in the request or response said so (#57).

Azure contract (api-version 2022-03-01, the version armreservations v1.1.0 calls: calculateexchange_client.go:98)

What the platform must change to benefit

toAzureExchangeSources (internal/api/handler_ri_exchange.go:639) builds sources from the request body with only ReservationID and Quantity. After bumping this module, every Azure exchange will be refused with ErrUnsupportedAppliedScope (sources[0]: unsupported applied scope: applied_scope_type is required) until the handler passes the matching entry from the owned listing it already fetches for requireAzureSourceOwnership, with the request's quantity, and map ErrUnsupportedAppliedScope to a 4xx (today mapAzureExchangeError turns anything that is not an *azcore.ResponseError 4xx into a generic 500). That refusal is intentional: the alternative is the silent Shared purchase. Hence Refs #57, not Closes: the end-to-end fix needs that platform change.

How verified

GOTOOLCHAIN=go1.26.6 GOWORK=off GOFLAGS='-p=2 -count=1' AWS_EC2_METADATA_DISABLED=true, in providers/azure:

  • go build ./..., go vet ./..., go test ./...: all pass. go test -race ./services/compute/: 251 passed.
  • golangci-lint run ./services/compute/...: no issues. gofmt -l: clean.
  • No other module imports providers/azure/services/compute.

Failing before: with the new tests and the new ExchangeableReservation fields but exchange_operations.go from origin/main:

--- FAIL: TestCalculateExchange_ValidationSources (0.00s)
    "azure: CalculateExchange returned no session id" does not contain "sources[0]: applied_scope_type is required"
    ...
--- FAIL: TestCalculateExchange_PreservesSingleScopeOfSources (0.00s)
    expected: "Single"
    actual  : "Shared"
    expected: []*string{(*string)(0x1cb1aa183180)}
    actual  : []*string(nil)
FAIL

Passing after: ok github.com/LeanerCloud/cloud-commitments-go/providers/azure/services/compute.

Mutation: disabling the same-scope check (if false && !s.sameAppliedScope(...)) fails ValidationSources/shared_mixed_with_single and ValidationSources/single_on_different_subscriptions. Reverted.

This is fixture-based evidence: no real Azure exchange was priced or executed.

Reviewer should challenge

Update: review fixes. appliedScopes is no longer sent as [] for Shared (asserted nil and absent from the marshalled JSON), refusals wrap ErrUnsupportedAppliedScope, Single requires exactly one scope, and scope lists are compared only for Single.

…I exchange

buildCalculateExchangeRequest defaulted every purchased reservation to
AppliedScopeType Shared when the target left it unset, and no caller set
it. Exchanging a reservation Single-scoped to one subscription therefore
bought a Shared replacement, moving the discount to every subscription
in the billing scope without telling the caller.

ExchangeableReservation now carries AppliedScopeType and AppliedScopes
from the tenant listing, and CalculateExchange purchases every target
with the sources' scope. A source with no reported scope, an unknown
scope type, Single without scopes, or sources whose scopes differ is
refused instead of being defaulted. ExchangeTarget.AppliedScopeType is
removed: it could not express a Single scope's subscription list and
its nil value was the Shared fallback.

Refs #57
@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm urgency/this-sprint Within the current sprint impact/few Limited audience effort/m Days type/bug Defect triaged Item has been triaged labels Oct 5, 2026
@coderabbitai

coderabbitai Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 4 billable files and costs up to $1.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 7 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available. Your 50 included PR review attempts over the past 7 days set your current allowance at 2 reviews per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-go/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 94cebd33-e011-48f2-9eef-eab1a5678c72
📥 Commits

Reviewing files that changed from the base of the PR and between 40427f6 and 0ac2dff.

📒 Files selected for processing (4)
  • providers/azure/services/compute/exchange.go
  • providers/azure/services/compute/exchange_operations.go
  • providers/azure/services/compute/exchange_operations_test.go
  • providers/azure/services/compute/exchange_test.go
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

…fusals

Shared exchanges no longer send "appliedScopes":[] (Azure says not to
specify it for Shared). Scope refusals wrap the exported
ErrUnsupportedAppliedScope so callers can map them to a client error.
Single requires exactly one scope, and scope lists are compared only
for Single sources.

Refs #57
@cristim
cristim merged commit 2d7f3f3 into main Oct 5, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant