Skip to content

feat(ladder): phase 4 - Azure (BillingBenefits savings plans + reservation exchange) #28

Open

Description

@cristim

Part of LeanerCloud/cloud-commitments-cli#1333 (commitment laddering automation). Phase 4 of 4: Azure implementation (Azure has both needed instruments: exchangeable Reservations and Savings Plans for Compute).

PRs:

  1. Azure read side: list existing savings plans via Microsoft.BillingBenefits; savings-plan utilization via Cost Management benefitUtilizationSummaries; usage baseline from Cost Management series. Reservation utilization already exists (getRIUtilizationViaAPI).
  2. Azure write side: new savings-plan purchase client (providers/azure/services/savingsplan/, Microsoft.BillingBenefits/savingsPlanOrders, validate-then-put, api-version confirmed against current docs) and reservation exchange client (POST /providers/Microsoft.Capacity/calculateExchange -> POST /providers/Microsoft.Capacity/exchange, async operation polling) with the same spend-cap guardrails as the AWS exchange path. All raw-HTTP clients MUST use httpclient.New() (SSRF/IMDS blocking).
  3. AzureLadder capability + enablement: implements LadderCapability with 2 layers (Reservation = base+buffer, SavingsPlan = flex); pipeline enablement for Azure scopes.

Activity

  1. 3 remaining items

  2. cristim commented on Nov 12, 2026

    @cristim
    MemberAuthor

    Owner decision (2026-11-12), recorded:

    • A2 (residual basis): Azure stays on the unmerged guard. GetUsageBaseline returns ErrBaselineBasisUnresolved (providers/azure/ladder/ladder.go:309-311, tested by TestAzureLadder_GetUsageBaselineIsBlocked). No residual series is built, because Azure has no on-demand series source yet. The AWS residual basis (providers/aws/ladder/baseline.go:124) is not mirrored until a recorded response confirms the filter.
    • C2 (new-term exchanges): ReshapeBuffer refuses every run that is not a dry run, before any client call, with errExchangeNewTerm (providers/azure/ladder/reshape.go, checkReshapeAllowed). Reason: every exchange target is a new reservation purchase whose term starts when bought, so every exchange starts a new term. Dry runs still price through CalculateExchange and never reach ExecuteExchange. The per-process cap is accepted as-is and no new cap was added.
    • D1 (exchange enablement): unchanged, owner-only.

    Root cause: no code change was needed for A2 (the guard already existed). C2 needed a new refusal, because the exchange path executed any priced exchange within the caps.

    Change: commit 19b06a496fafe9da9044faf00e0d3d826c993d03 on main, Refs #28.

    Tests: TestAzureLadder_ReshapeBuffer_NewTermExchangeNeverReachesExecute (regression, fails without the refusal) and TestAzureLadder_ReshapeBuffer_DryRunPricesTargetWithoutExecuting. Validation and scope tests (RefusesNilOrBadCaps, RejectsForeignScope) now use a dry run so they still reach their own checks. Removed the execute, halt, daily-cap and concurrency tests, which covered paths that are now unreachable.

    Verification: mutation probe (refusal disabled) made the regression test fail; the file was restored and checked with cmp. In providers/azure: go test -count=1 ./..., go vet ./..., gofmt -l . and golangci-lint run ./... (0 issues) all passed. Adversarial review: two passes with the same reviewer; pass 1 found the two weakened validation and scope tests, fixed; pass 2 no actionable findings.

    CI on 19b06a4: "CI - Build & Test" and "pre-commit" both succeeded, with all jobs run.

    Known leftover: the execute, halt and daily-cap code in reshape.go is now unreachable. Remove it when D1 is decided. Issue stays open for D1.

  3. cristim commented on Nov 14, 2026

    @cristim
    MemberAuthor

    A2 (usage baseline) closed by the owner's 2026-11-12 decision: Azure stays on the unmerged guard (ErrBaselineBasisUnresolved, providers/azure/ladder/ladder.go:309-311), so no residual series is built. Recorded by claude-go-cli-mcp, 2026-11-13. #28 stays open for D1, the Azure switch-on, which is owner-gated.

  4. cristim commented on Nov 14, 2026

    @cristim
    MemberAuthor

    Non-D1 slices are landed or decided: C2 (refuse exchanges that start a new Azure term) is on main (providers/azure/ladder/reshape.go:101, errExchangeNewTerm). A2 stays unmerged until a recorded Basis response (2026-11-12 decision). D1 (the Azure switch-on) is owner-gated and remains open. Commented by claude-go-cli-mcp, 2026-11-14.

  5. cristim commented on Nov 14, 2026

    @cristim
    MemberAuthor

    C2 is on main: ladder exchanges that start a new Azure term are refused (providers/azure/ladder/reshape.go:101, errExchangeNewTerm), landed in 19b06a4. A2 stays unmerged until a recorded Basis response (12 Nov decision). D1 remains owner-gated. Recorded by claude-go-cli-mcp, 2026-11-14.

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