Repository navigation
fix(azure): let exchange targets request a Monthly billing plan - #472
Conversation
ExchangeTarget had no billing-plan field and buildCalculateExchangeRequest set ReservationBillingPlanUpfront on every target, so every Azure reservation exchange was priced and committed upfront regardless of what the caller wanted, contradicting the file's no-coercion contract. Add an optional ExchangeTarget.BillingPlan. Unset keeps Upfront, so existing callers send a byte-identical CalculateExchange body; Upfront or Monthly (exact SDK enum spelling) is sent per target; anything else is a validation error before any request. ExecuteExchange sends only the session ID, so the executed exchange carries the plan that was priced. Tests run the production SDK wrappers over a fake HTTP transport (new test-only ARM client options hook) and assert the wire bodies. Closes LeanerCloud#89 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent adversarial review: CLEAN at Blocking findings: none. What the reviewer checked against the SDK source (armreservations v1.1.0,
Isolation: Logic:
Mutation probes: 5 run, all caught by a failing test.
Non-blocking notes:
Local verification (macOS, fixture/mock: fake HTTP transport around the real SDK clients and serialization; no live Azure call, no exchange):
|
|
Merge evidence at The platform follow-up to send |
|
@IAmJSD this is ready to merge but BEHIND main: the go ruleset requires up-to-date branches, and main moved to |
|
@IAmJSD gentle ping: this is still blocked only on being BEHIND main (the ruleset requires up-to-date branches). Review, CI and evidence are all done at |
|
Merged |
|
Merge evidence at
Thanks @IAmJSD for the update. |
Root cause
ExchangeTarget(providers/azure/services/compute/exchange_operations.go) had no billing-plan field, andbuildCalculateExchangeRequesthardcodedBillingPlan: ReservationBillingPlanUpfronton everyreservationsToPurchaseentry. So every Azure reservation exchange was priced, and then committed, as Upfront. Nothing in the request or the response showed this, and it contradicts the file's own contract that nothing is coerced.Fix (providers/azure only, no
pkg/change)ExchangeTarget.BillingPlan armreservations.ReservationBillingPlan."") →Upfront, the same plan as before, so existing callers (the platform today) send a byte-identical body.Upfront/Monthly(exact SDK enum spelling, checked againstPossibleReservationBillingPlanValues()) → sent as that target'sbillingPlan, separately for each target.monthly,UPFRONT,Monthly,Annual, ...) →targets[i].billing_plan "<v>" is not a supported billing plan, returned fromvalidateExchangeTargetsbefore any HTTP call. It is never coerced to Upfront.ExchangeRequestholds onlysessionId(SDK v1.1.0ExchangeRequestProperties), so the plan cannot be sent again at execute time and doesn't need to be. Azure commits the purchase terms it priced in the CalculateExchange session, so threading the plan into calculate also carries it into the executed exchange. The tests pin this: the execute body is exactly{"properties":{"sessionId":...}}.Client.exchangeClientOptions *arm.ClientOptionsis nil in production (SDK defaults, same as the oldnil) and is set only throughexport_test.go. With it, the tests run the productionbuildCalculateExchangeCaller/buildDoExchangeCallerLRO wrappers and the SDK's own JSON serialization against a fakepolicy.Transporter. They don't stub around them.Wire body (CalculateExchange
reservationsToPurchase[].properties)BillingPlanunset"billingPlan":"Upfront""billingPlan":"Upfront"(full body identical, golden-asserted)BillingPlan: Monthly"Upfront")"billingPlan":"Monthly"BillingPlan: "monthly"Execute body is
{"properties":{"sessionId":"..."}}in every case, before and after.Does Azure require the target plan to match the source?
No. Microsoft's exchange policy says the new reservation's total lifetime commitment must be at least the returned remaining commitment "whether you choose to pay monthly or upfront", and "If you're exchanging for a different size, series, region, or payment frequency, the term is reset for the new reservation." So either plan is allowed for any source. This module therefore does not tie the target plan to the source's. Whether Monthly is actually offered for a given SKU/agreement is Azure's call, reported back from CalculateExchange (policy error / API error), same as for terms. This is documented on the field.
Platform follow-up (LeanerCloud/cloud-commitments-platform, not in this PR)
The HTTP boundary has no billing-plan input yet, so after this PR every platform exchange is still Upfront, unchanged:
internal/api/handler_ri_exchange.go: addBillingPlan string \json:"billing_plan,omitempty"`toAzureExchangeTargetBody`.validateAzureExchangeTargets: return a 400 for a non-empty value that isn'tUpfront/Monthly. Decide whether to normalize case at the HTTP layer. The provider is exact-match on purpose.toAzureExchangeTargets: passBillingPlan: armreservations.ReservationBillingPlan(t.BillingPlan)through.internal/api/ri_exchange_idempotency.goazureExchangeIdempotencyKey: include the (defaulted) billing plan in the target fingerprint. Otherwise an Upfront and a Monthly submit of the same targets collide as one logical exchange.providers/azurepin ingo.modto the commit that merges this. Surface the plan in the UI/CLI/MCP request builders as needed.Verification (local, macOS, at 506b8c2)
providers/azure:go build ./...,go vet ./...,go test ./...(go.work) andGOWORK=off go build/vet+GOWORK=off go test -race -short ./...: all pass. Only this module changed.pkg/is untouched, so no pkg-first split is needed.services/compute/exchange_billing_plan_sdk_test.go(fake HTTP transport; never reaches Azure, and the package's network-guard proxy would catch it if it did):TestExchangeSDK_UnsetBillingPlanKeepsUpfrontWireBody: full golden CalculateExchange body plus execute body. The same golden passes on origin/main's request builder (with only the options hook patched in), which proves the unset path is byte-identical.TestExchangeSDK_MonthlyBillingPlanReachesWire:[Monthly, Upfront]targets →"Monthly","Upfront"on the wire. Execute sends only the session.TestExchangeSDK_InvalidBillingPlanRejectedBeforeHTTP: 4 bad spellings → error, zero recorded requests.--allow-parallel-runners, repo.golangci.yml): 0 issues.gocyclo -over 10on changed non-test files: clean (validateExchangeTargets= 9).--register-aws+scripts/setup-git-secrets.shpatterns + repo.gitallowed, fresh repo, isolated HOME): changed files scan clean. A password-literal positive control is caught.Note:
exchange_operations.gowas already over the 500-line guideline (570) before this change; it's now 615.Labels
Mirrored from #89:
priority/p2, severity/medium, urgency/this-quarter, impact/few, effort/s, type/bug, triaged(applied).Closes #89
🤖 Generated with Claude Code
Summary by CodeRabbit
UpfrontandMonthly, are accepted.