Skip to content

fix(azure): switch 7 reservation clients to two-step calculatePrice->purchase flow (closes #677) - #680

Merged
cristim merged 6 commits into
feat/multicloud-web-frontendfrom
fix/issue-677-azure-calculate-price
May 22, 2026
Merged

fix(azure): switch 7 reservation clients to two-step calculatePrice->purchase flow (closes #677)#680
cristim merged 6 commits into
feat/multicloud-web-frontendfrom
fix/issue-677-azure-calculate-price

Conversation

@cristim

@cristim cristim commented May 22, 2026

Copy link
Copy Markdown
Member

Summary

  • Fixes the 400 BadRequest "Session timed out - Call CalculatePrice again" error that affects newer Azure SKU families (e.g., Standard_B2ats_v2, Burstable v2 series) when purchasing reservations
  • Replaces the broken single-step PUT reservationOrders/{client-generated-id} pattern with Azure's required two-step flow: POST calculatePrice to mint an Azure-assigned reservationOrderId, then POST reservationOrders/{azure-id}/purchase
  • Shared helper in providers/azure/services/internal/reservations (new package) used by all 7 affected clients

Services changed (7)

compute, database, cache, search, cosmosdb, managedredis, synapse

Implementation notes

Two-step flow: calculatePrice mints a session-bound reservationOrderId; purchase commits using that Azure-assigned ID. On "Session timed out" from the purchase endpoint, calculatePrice is re-run from scratch (up to 3 attempts). Other 4xx/5xx errors return immediately.

Idempotency (Option B): Azure now mints the order ID, so client-supplied UUIDs are no longer viable. Caller-level deduplication uses the purchase-automation tag already attached to each reservation request body. No schema change required.

Lambda budget (#667): calculatePrice (~1-2 s) + purchase (~2-5 s) + auth (~1 s) = ~8 s worst-case per purchase, well within the 60 s Lambda limit.

API version: Both endpoints pinned to 2022-11-01 (last stable GA version). github.com/google/uuid dependency removed from all 7 clients.

Test plan

  • cd providers/azure && go test ./... -count=1 - 530 tests pass, go vet clean
  • Two-step mock pattern verified for all 7 clients (calculatePrice + purchase HTTP expectations)
  • Session-timeout retry tested in shared helper (TestDoPurchaseTwoStep_SessionTimeoutThenSuccess)
  • Input validation paths (empty resource type, zero count, bad term) unchanged
  • TestPurchaseCommitment_withSource verifies automation tag appears in calculatePrice request body

Closes #677
Refs #641, #667, #632, #669, #671

Summary by CodeRabbit

  • New Features

    • All Azure reservation purchases now use a two-step calculatePrice→purchase flow and preserve server-minted order IDs.
    • Purchase requests include optional automation/source tags when provided.
  • Bug Fixes

    • Automatic retry on session-timeout errors during reservation purchases.
    • Clearer error messages that indicate which purchase step failed.
    • Improved reliability across Azure services (compute, databases, Redis, search, synapse, Cosmos DB).
  • Tests

    • Stronger, more precise test coverage for the two-step reservation flow and edge cases.

Review Change Stack

cristim added 2 commits May 22, 2026 17:03
…e helper

Introduces providers/azure/services/internal/reservations with
DoPurchaseTwoStep, IsSessionTimeout, CalculatePriceURL, and PurchaseURL.

The helper centralises the two-call flow (calculatePrice mints a
session-bound reservationOrderId; purchase commits it) and retries up to
3 times on Session timed out 400s by re-running calculatePrice from
scratch. Refs #677.
…flow

Fixes the 400 "Session timed out" errors on newer Azure SKU families
(e.g., Standard_B2ats_v2, Burstable v2) by replacing the broken single-step
PUT reservationOrders/{client-id} pattern with the required two-step flow:

  1. POST calculatePrice -- mints an Azure session-bound reservationOrderId
  2. POST reservationOrders/{azure-id}/purchase -- commits the order

All 7 affected clients now delegate to the shared reservations helper:
compute, database, cache, search, cosmosdb, managedredis, synapse.

Idempotency (Option B): Azure mints the order ID at calculatePrice time so
client-supplied IDs are no longer feasible. Caller-level deduplication uses
the purchase-automation tag already attached to each reservation request.
The `uuid` dependency is removed from all 7 clients.

Tests updated to the two-step mock pattern (calculatePrice + purchase HTTP
expectations). Total: 530 tests pass, go vet clean.

Closes #677
Refs #641, #667, #632, #669, #671
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@cristim has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 29 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a94a6b0a-2a72-4a61-a44d-4663cbc3345a

📥 Commits

Reviewing files that changed from the base of the PR and between 86b49ef and d283edc.

📒 Files selected for processing (15)
  • providers/azure/services/cache/client.go
  • providers/azure/services/cache/client_test.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/cosmosdb/client.go
  • providers/azure/services/cosmosdb/client_test.go
  • providers/azure/services/database/client.go
  • providers/azure/services/database/client_test.go
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/managedredis/client.go
  • providers/azure/services/managedredis/client_test.go
  • providers/azure/services/search/client.go
  • providers/azure/services/search/client_test.go
  • providers/azure/services/synapse/client.go
  • providers/azure/services/synapse/client_test.go
📝 Walkthrough

Walkthrough

This PR introduces a shared reservations helper that performs Azure’s calculatePrice→purchase two-step flow (with session-timeout retry) and refactors all reservation-based Azure service clients and their tests to delegate purchase orchestration to that helper.

Changes

Azure Reservations Two-Step Purchase Flow

Layer / File(s) Summary
Reservations helper package: two-step purchase orchestration
providers/azure/services/internal/reservations/purchase.go, providers/azure/services/internal/reservations/purchase_test.go
New shared reservations package implementing DoPurchaseTwoStep, URL builders, IsSessionTimeout, and HTTPClient interface. Orchestrates calculatePrice→purchase with limited retries on "Session timed out", treats 200/201/202 as success, and includes comprehensive unit tests.
Cache, Search, CosmosDB, Database, ManagedRedis clients
providers/azure/services/cache/client.go, providers/azure/services/cache/client_test.go, providers/azure/services/search/client.go, providers/azure/services/search/client_test.go, providers/azure/services/cosmosdb/client.go, providers/azure/services/cosmosdb/client_test.go, providers/azure/services/database/client.go, providers/azure/services/database/client_test.go, providers/azure/services/managedredis/client.go, providers/azure/services/managedredis/client_test.go
Refactor to call reservations.DoPurchaseTwoStep instead of constructing/executing direct PUTs. Remove unused io/uuid imports, add reservations import, inject purchase tag when opts.Source is set (ManagedRedis), and update tests to path-match calculatePrice and purchase endpoints and assert CommitmentID equals the Azure-minted reservationOrderId.
Compute client with regression tests
providers/azure/services/compute/client.go, providers/azure/services/compute/client_test.go
Compute client refactor removes local retry helpers and UUID-based order ID generation, calls reservations.DoPurchaseTwoStep, and adds regression tests validating the two-step call sequence and session-timeout retry behavior that re-runs calculatePrice and re-attempts purchase with a new Azure-minted order ID.
Synapse client with inline payload and tags
providers/azure/services/synapse/client.go, providers/azure/services/synapse/client_test.go
Synapse client now builds the reservation payload inline and delegates purchase to reservations.DoPurchaseTwoStep. Tests capture calculatePrice request body to assert tag injection when PurchaseOptions.Source is set and verify two-step flows and error handling.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • LeanerCloud/CUDly#622: Overlaps in Synapse PurchaseCommitment implementation history; related to purchase flow changes.
  • LeanerCloud/CUDly#653: Prior work that made reservationOrderId deterministic from idempotency token; this PR replaces that client-generated order-id approach with Azure-minted order IDs via two-step flow.

Suggested labels

priority/p2, severity/high, urgency/this-quarter, impact/many, effort/l

Poem

🐰 A hop, a nibble, then a clever tweak,

Calculate then purchase — no more PUTs that leak.
Azure mints the order; the rabbit skips a beat,
Retry on timeout, keep the session neat.
Six clients twirl the two-step dance so fleet.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.77% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: switching 7 Azure reservation clients from single-step to two-step purchase flow.
Linked Issues check ✅ Passed The PR implements all coding objectives from #677: two-step calculatePrice→purchase flow across 7 clients, session-timeout retry logic, idempotency preservation via Azure-minted IDs and purchase tags, comprehensive unit tests, and API version pinning.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the two-step reservation flow and supporting infrastructure. The new reservations package, client updates across 7 services, corresponding test updates, and removal of unused dependencies are all required for the #677 fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-677-azure-calculate-price

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

@cristim cristim added triaged Item has been triaged priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens urgency/now Drop other things impact/all-users Affects every user effort/m Days type/bug Defect labels May 22, 2026
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
providers/azure/services/cache/client.go (1)

296-313: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require opts.Source before relying on tag-based dedupe.

After removing the client-generated order ID, this flow only emits the purchase-automation tag when opts.Source is set. If Source is empty, a re-drive can run a fresh calculatePrice -> purchase sequence with no stable dedupe signal, which breaks the PR’s idempotency goal. Please fail fast here or provide another mandatory idempotency key. The same invariant appears in the sibling Search, Cosmos DB, and Database clients.

Suggested guard
 func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) {
 	result := common.PurchaseResult{
 		Recommendation: rec,
 		DryRun:         false,
 		Success:        false,
 		Timestamp:      time.Now(),
 	}
+
+	if opts.Source == "" {
+		result.Error = fmt.Errorf("purchase source is required for idempotent Azure reservation purchases")
+		return result, result.Error
+	}
 
 	termYears := 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/cache/client.go` around lines 296 - 313, The code
currently calls applyPurchaseAutomationTag(requestBody, opts.Source) and
proceeds without validating opts.Source; ensure opts.Source is non-empty before
relying on tag-based dedupe by adding a guard that fails fast (returning an
error in result) when opts.Source == "" so purchase flows (e.g.,
reservations.DoPurchaseTwoStep) cannot proceed without a stable idempotency key;
update the same validation pattern where other clients use
applyPurchaseAutomationTag (Search, Cosmos DB, Database) to enforce the
invariant.
🧹 Nitpick comments (2)
providers/azure/services/search/client_test.go (1)

830-860: ⚡ Quick win

Cover the tag that now gates idempotency.

This regression test proves the two HTTP calls, but it still passes if applyPurchaseAutomationTag stops sending common.PurchaseTagKey. Since duplicate protection now depends on that tag, add a case with common.PurchaseOptions{Source: ...} and assert the captured calculatePrice body contains it. This same check would be valuable in the sibling client suites too.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/search/client_test.go` around lines 830 - 860, The
test TestSearchClient_PurchaseCommitment_TwoStepFlow must assert that the
idempotency tag is sent; modify the test to call client.PurchaseCommitment with
a PurchaseOptions including Source (e.g. common.PurchaseOptions{Source:
"tests"}) and update the mock for the calculatePrice POST (the MockHTTP matcher
used in this test) to capture and validate the request body contains the tag
key/value produced by applyPurchaseAutomationTag (use common.PurchaseTagKey and
the Source value) before returning the mocked response; keep existing assertions
and call counts intact.
providers/azure/services/managedredis/client_test.go (1)

511-529: 💤 Low value

Consider adding a test for opts.Source tag injection.

The PurchaseCommitment method conditionally injects tags when opts.Source is non-empty (lines 273-275 in client.go), but no test verifies this behavior. Consider adding a test that captures the request body and asserts the tags field is present when opts.Source is set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/managedredis/client_test.go` around lines 511 - 529,
Add a new unit test (or extend TestPurchaseCommitment_Success) to verify tag
injection by PurchaseCommitment when PurchaseOptions.Source is set: use the same
mockHTTPClient and set PurchaseOptions{Source: "some-source"}, then have the
mock's Do matcher for the purchase request capture and unmarshal the request
body and assert the JSON contains a "tags" object with the expected source
value; keep the existing calculatePrice stub call (calcPriceRespJSON) and
response assertions (result.Success, CommitmentID, Cost) so only the purchase
request matcher is enhanced to validate the tags injection by
PurchaseCommitment.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@providers/azure/services/internal/reservations/purchase.go`:
- Around line 119-123: The retry backoff currently uses time.Sleep which ignores
ctx cancellation; update the retry path in purchase.go (the loop that checks
IsSessionTimeout(purchaseErr) and uses purchaseRetryDelay and
attempt/purchaseMaxAttempts) to wait on a context-aware timer instead: replace
time.Sleep(purchaseRetryDelay) with a select that either returns early on
ctx.Done() (propagating ctx.Err()) or waits on time.After(purchaseRetryDelay)
(or a time.Timer.C) before continuing, so cancellation/deadline stops the
backoff immediately.

---

Outside diff comments:
In `@providers/azure/services/cache/client.go`:
- Around line 296-313: The code currently calls
applyPurchaseAutomationTag(requestBody, opts.Source) and proceeds without
validating opts.Source; ensure opts.Source is non-empty before relying on
tag-based dedupe by adding a guard that fails fast (returning an error in
result) when opts.Source == "" so purchase flows (e.g.,
reservations.DoPurchaseTwoStep) cannot proceed without a stable idempotency key;
update the same validation pattern where other clients use
applyPurchaseAutomationTag (Search, Cosmos DB, Database) to enforce the
invariant.

---

Nitpick comments:
In `@providers/azure/services/managedredis/client_test.go`:
- Around line 511-529: Add a new unit test (or extend
TestPurchaseCommitment_Success) to verify tag injection by PurchaseCommitment
when PurchaseOptions.Source is set: use the same mockHTTPClient and set
PurchaseOptions{Source: "some-source"}, then have the mock's Do matcher for the
purchase request capture and unmarshal the request body and assert the JSON
contains a "tags" object with the expected source value; keep the existing
calculatePrice stub call (calcPriceRespJSON) and response assertions
(result.Success, CommitmentID, Cost) so only the purchase request matcher is
enhanced to validate the tags injection by PurchaseCommitment.

In `@providers/azure/services/search/client_test.go`:
- Around line 830-860: The test TestSearchClient_PurchaseCommitment_TwoStepFlow
must assert that the idempotency tag is sent; modify the test to call
client.PurchaseCommitment with a PurchaseOptions including Source (e.g.
common.PurchaseOptions{Source: "tests"}) and update the mock for the
calculatePrice POST (the MockHTTP matcher used in this test) to capture and
validate the request body contains the tag key/value produced by
applyPurchaseAutomationTag (use common.PurchaseTagKey and the Source value)
before returning the mocked response; keep existing assertions and call counts
intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 335e0f54-1f25-44a7-95d6-7a93113af4fb

📥 Commits

Reviewing files that changed from the base of the PR and between dc2ee71 and 02930ec.

📒 Files selected for processing (16)
  • providers/azure/services/cache/client.go
  • providers/azure/services/cache/client_test.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/cosmosdb/client.go
  • providers/azure/services/cosmosdb/client_test.go
  • providers/azure/services/database/client.go
  • providers/azure/services/database/client_test.go
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/internal/reservations/purchase_test.go
  • providers/azure/services/managedredis/client.go
  • providers/azure/services/managedredis/client_test.go
  • providers/azure/services/search/client.go
  • providers/azure/services/search/client_test.go
  • providers/azure/services/synapse/client.go
  • providers/azure/services/synapse/client_test.go

Comment thread providers/azure/services/internal/reservations/purchase.go
- Honor ctx cancellation during retry backoff in DoPurchaseTwoStep:
  replace time.Sleep with a select on ctx.Done() so a deadline or
  cancel propagates immediately instead of blocking for purchaseRetryDelay.

- Add tag-injection regression tests to search and managedredis suites:
  assert purchase-automation tag is present in the request body when
  opts.Source is set and absent when it is empty.
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

Addressed CodeRabbit findings in commit 86b49ef.

Finding File Verdict Action
Major - time.Sleep ignores ctx cancellation in retry backoff providers/azure/services/internal/reservations/purchase.go:122 Addressed Replaced time.Sleep(purchaseRetryDelay) with a select on ctx.Done() / time.After(purchaseRetryDelay) so a deadline or cancellation propagates immediately.
Major - Require opts.Source before relying on tag-based dedupe (cache/search/cosmosdb/database) providers/azure/services/cache/client.go:296-313 (outside diff) Dismissed The tag is explicitly optional by design. applyPurchaseAutomationTag already no-ops on an empty source and normalizePurchaseSource can legitimately return "" when the execution record has no source (see execution.go:526-537 comment: "proceeding untagged"). Adding a hard fail would break production executions where source is unavailable. Idempotency for those cases relies on the Azure-minted order ID stored as CommitmentID, not on the tag.
Nitpick - Cover tag that gates idempotency in search test providers/azure/services/search/client_test.go:830-860 Addressed Added TestSearchClient_PurchaseCommitment_TagInjection with two sub-tests (source set / source empty), asserting the calculatePrice request body contains / omits the tag accordingly.
Nitpick - Cover tag injection in managedredis test providers/azure/services/managedredis/client_test.go:511-529 Addressed Added TestPurchaseCommitment_TagInjection with the same two sub-test pattern against the purchase request body.

Tests: 320 passed across 8 azure/services packages, go build ./... and go vet clean.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the detailed breakdown — the fixes look well-considered:

  • ctx-aware retry backoff — the select { case <-ctx.Done(): ... case <-time.After(purchaseRetryDelay): } pattern is the correct fix; it ensures Lambda deadline cancellations propagate without waiting out the full sleep.
  • opts.Source dismissal — the rationale is sound. applyPurchaseAutomationTag is already a best-effort no-op on empty source, and forcing a hard fail would break executions where source is legitimately unavailable. The Azure-minted CommitmentID acting as the idempotency anchor for untagged flows is a reasonable fallback.
  • Tag injection tests (TestSearchClient_PurchaseCommitment_TagInjection, TestPurchaseCommitment_TagInjection) — good to have the two sub-test pattern (source set / source empty) covering both branches explicitly.

Triggering the incremental review on the new commit.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
providers/azure/services/internal/reservations/purchase.go (1)

137-137: ⚡ Quick win

Prefer bytes.NewReader over strings.NewReader(string(bodyBytes)).

Converting []byte to string and then to io.Reader allocates an intermediate string on every HTTP call. Use bytes.NewReader(bodyBytes) for a zero-copy alternative.

♻️ Proposed fix

Update the import block:

 import (
+	"bytes"
 	"context"
 	"encoding/json"

Apply to line 137 in doCalculatePrice:

-	req, err := http.NewRequestWithContext(ctx, http.MethodPost, calcURL, strings.NewReader(string(bodyBytes)))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, calcURL, bytes.NewReader(bodyBytes))

Apply to line 170 in doPurchase:

-	req, err := http.NewRequestWithContext(ctx, http.MethodPost, purchaseURL, strings.NewReader(string(bodyBytes)))
+	req, err := http.NewRequestWithContext(ctx, http.MethodPost, purchaseURL, bytes.NewReader(bodyBytes))

Also applies to: 170-170

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/internal/reservations/purchase.go` at line 137,
Replace the intermediate string allocation when creating the HTTP request body
in doCalculatePrice and doPurchase: use bytes.NewReader(bodyBytes) instead of
strings.NewReader(string(bodyBytes)); also update the import block to add
"bytes" (and remove "strings" if it becomes unused) so the code compiles without
the extra allocation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@providers/azure/services/internal/reservations/purchase.go`:
- Line 137: Replace the intermediate string allocation when creating the HTTP
request body in doCalculatePrice and doPurchase: use bytes.NewReader(bodyBytes)
instead of strings.NewReader(string(bodyBytes)); also update the import block to
add "bytes" (and remove "strings" if it becomes unused) so the code compiles
without the extra allocation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce3ada37-905b-494c-9f93-fc6cf030552e

📥 Commits

Reviewing files that changed from the base of the PR and between 02930ec and 86b49ef.

📒 Files selected for processing (3)
  • providers/azure/services/internal/reservations/purchase.go
  • providers/azure/services/managedredis/client_test.go
  • providers/azure/services/search/client_test.go

…der(string(bs))

Address remaining CR nitpick from PR #680 pass 2. Wrapping a []byte in an
io.Reader via strings.NewReader(string(bodyBytes)) forces a copy of the
underlying bytes; bytes.NewReader takes the slice directly. Two callsites
fixed (doCalculatePrice + doPurchase). Tests + go build + go vet clean.
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 2 commits May 22, 2026 18:00
… dedupe

CodeRabbit pass-3 round-1 A1 (outside-diff Major): Azure's reservation API
mints the order ID server-side in calculatePrice, so client-supplied
GUIDs cannot deduplicate re-driven purchases. The only stable dedupe
signal CUDly controls is the purchase-automation tag derived from
opts.Source via applyPurchaseAutomationTag (or the inline equivalent in
compute / managedredis). When opts.Source is empty the tag is dropped
silently and a re-driven purchase cannot recognise the prior attempt,
producing duplicate reservations.

Add a guard at the entry of PurchaseCommitment in every Azure service
that uses the two-step calculatePrice -> purchase flow with tag-based
dedupe so the request fails fast before any cloud API call:

  if opts.Source == "" {
      result.Error = fmt.Errorf("purchase source is required for ...")
      return result, result.Error
  }

Services covered (7): cache, database, search, cosmosdb, synapse,
compute, managedredis. Savingsplans is intentionally excluded -- it
uses the Azure OrderAlias SDK with native DisplayName-based dedup and
takes opts.PurchaseOptions as `_`, so the same invariant does not apply.

Per-service regression tests pin the guard: every PurchaseCommitment
*_RequiresSource test asserts the call returns an error containing
"purchase source is required" and that no HTTP call is issued. Existing
tests that previously passed common.PurchaseOptions{} are updated to
pass Source: common.PurchaseSourceCLI so they continue to exercise the
intended code paths.

Production safety: the call sites in internal/purchase/execution.go
already populate opts.Source from m.normalizePurchaseSource(exec); the
guard only blocks the silent untagged fallback (when the upstream
NormalizeSource rejects a malformed value), which is exactly the case
the CR finding flags as a duplicate-purchase risk.

go build ./... + go vet ./... + go test ./providers/azure/... + tests
for callers (internal/purchase, cmd) all pass.
… body

CodeRabbit pass-3 round-1 N1 (nitpick) + N2 (nitpick covered by same shape):
the existing TwoStepFlow / Success regression tests for the Azure
two-step calculatePrice -> purchase flow only assert HTTP call counts
and outcome -- they do not capture the calculatePrice body or check that
opts.Source has been embedded as a purchase-automation tag. Without that
assertion the dedupe invariant (the only stable signal Azure's
server-minted-order-ID API exposes) could regress silently: tag drop
elsewhere in the code (e.g. helper refactor, JSON shape change) would
not fail the suite.

Add a *_PurchaseCommitment_TagInjection test in each service that lacks
one today: cache, database, cosmosdb, compute. Each test:

  1. Wires a mock that captures the calculatePrice request body via
     io.ReadAll and rewinds it with bytes.NewReader so the production
     code sees an intact request.
  2. Drives PurchaseCommitment with common.PurchaseOptions{Source:
     PurchaseSourceWeb}.
  3. Unmarshals the captured body and asserts the top-level "tags" map
     contains common.PurchaseTagKey ("purchase-automation") -> source.

Services already covered by an equivalent test, intentionally untouched:

  - search:        TestSearchClient_PurchaseCommitment_TagInjection
  - managedredis:  TestPurchaseCommitment_TagInjection
  - synapse:       TestPurchaseCommitment_withSource (asserts both
                   "purchase-automation" and the source value are present
                   in the captured body)

savingsplans excluded -- different API (OrderAlias) with native
DisplayName-based dedupe; opts.Source is `_`-ignored at the entry
point.

Net effect: the tag invariant is now pinned by a dedicated regression
test for every Azure two-step service.
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit 65ecbf8 into feat/multicloud-web-frontend May 22, 2026
4 of 5 checks passed
cristim added a commit that referenced this pull request May 22, 2026
…oss 5 services (closes #685)

Replace space-containing displayName format strings with underscore-form
prefixes and pipe through SanitizeDisplayName in all 5 Azure service clients
(cache, compute, cosmosdb, database, search). Fixes HTTP 400 DisplayNameInvalid
errors from Azure's Reservations API when SKU names contain unusual characters.

Extends each service's PurchaseCommitment test with a body-capture assertion
that guards the [A-Za-z0-9_-]{1,64} allowlist going forward. The capture mocks
the calculatePrice POST so it works correctly with the two-step
calculatePrice->purchase flow established by #680.
cristim added a commit that referenced this pull request May 22, 2026
…Price (closes #685) (#686)

* fix(azure/reservations): add SanitizeDisplayName helper for Azure DisplayName allowlist

Azure's Reservations API rejects DisplayName values that contain characters
outside [A-Za-z0-9_-] with HTTP 400 DisplayNameInvalid. Add a shared helper
in services/internal/reservations that replaces non-conforming characters
with '_', collapses consecutive non-conforming chars into a single '_', and
truncates to 64 characters.

* fix(azure): use SanitizeDisplayName + underscore-form displayName across 5 services (closes #685)

Replace space-containing displayName format strings with underscore-form
prefixes and pipe through SanitizeDisplayName in all 5 Azure service clients
(cache, compute, cosmosdb, database, search). Fixes HTTP 400 DisplayNameInvalid
errors from Azure's Reservations API when SKU names contain unusual characters.

Extends each service's PurchaseCommitment test with a body-capture assertion
that guards the [A-Za-z0-9_-]{1,64} allowlist going forward. The capture mocks
the calculatePrice POST so it works correctly with the two-step
calculatePrice->purchase flow established by #680.

* feat(azure/reservations): rich self-describing displayName for purchases

Extend the Azure DisplayName fix from #686 with a richer, parseable
format that mirrors the AWS RI CSV's ReservationId column shape so
operators can identify reservations in the Azure portal without
cross-referencing the purchase audit log.

Format (each segment separated by '-'):
  {svc}-{region}-{sku}-{count}x-{term}-{paymt}-{ts}-{rand}

  vm     -eastus    -Standard_D2a_v4-1x-1yr-allup -20260522T190000-a1b2c3d4
  redis  -westeurope-Premium_P1     -1x-1yr-allup -20260522T190000-a1b2c3d4
  cosmos -northeurope-EnableCassandra-1x-1yr-allup-20260522T190000-a1b2c3d4
  sql    -centralus -GP_Gen5_2      -1x-1yr-allup -20260522T190000-a1b2c3d4
  search -westus2   -standard2      -1x-1yr-allup -20260522T190000-a1b2c3d4

Output is always sanitized to Azure's [A-Za-z0-9_-]{1,64} allowlist.
When the composed string would exceed 64 chars (long region + long
SKU + high count), the builder progressively drops the optional tail
segments — random suffix first, then timestamp, then payment-option.
The service code, region, SKU, count, and term are NEVER dropped:
those are the high-signal segments operators rely on. With every
optional segment dropped, the joined required segments are truncated
as a last-resort safety net so the builder never emits >64 chars.

Previous format was `VM_Reservation_<SKU>` (and equivalents per
service) — operators reported these were indistinguishable across
purchases. The new identifier carries enough context to identify the
specific reservation purchase from the portal alone.

Implementation lives in
providers/azure/services/internal/reservations/displayname.go
alongside the existing SanitizeDisplayName helper, and is wired into
all 5 service clients (cache, compute, cosmosdb, database, search).
Time and random sources are injectable so tests can pin them; tests
cover happy-path, per-service shape, length-fit truncation at each
drop priority (random → timestamp → payment), payment normalization,
sanitization of dirty input, embedded-dash collapse, determinism,
and UTC normalization. The 5 per-service allowlist regression tests
are extended to also assert the service-code prefix and SKU presence
so accidental swaps in the literal strings are caught.

Refs #685

* fix(azure/reservations): repair progressive-drop loop in BuildDisplayName

Address CodeRabbit findings on PR #686.

Critical: BuildDisplayName's progressive-drop loop was broken. It called
SanitizeDisplayName(candidate) inside the loop before checking the
length cap. Because the sanitizer hard-truncates to 64 chars, the
condition len(candidate) <= 64 was vacuously true on iteration 1 and
the loop exited immediately, returning a mid-string-truncated full
format instead of cleanly dropping the random suffix, timestamp, and
payment segments. The existing tests passed only by coincidence (all
test inputs happened to land their required segments within the first
64 bytes of the full format).

Fix: check the pre-sanitized length to gate segment-dropping, and
sanitize only on the exit path. Each segment is already conformant via
normalizeSegment, so the in-loop sanitization was both wrong and
redundant.

Add three regression tests that fail against the pre-fix code:
- DropLoopActuallyDropsTimestamp: forces the loop to drop both random
  and timestamp; asserts the exact clean output and that no timestamp
  digits leak in.
- DropLoopActuallyDropsPayment: forces required-only fallback; asserts
  no payment fragment survives.
- ZeroNowReplacedByWallClock: asserts a zero-value Now is replaced by
  time.Now() rather than emitting the placeholder "00010101T000000".

Also address three minor doc/safety findings on the same file:
- Clarify isAllowedDisplayNameChar godoc so the [A-Za-z0-9-] description
  matches reality (underscores are passed through by the caller).
- Guard the zero-value Now case so production callers that forget to
  set it get a real timestamp instead of "00010101T000000".
- Document generateRandSuffix's silent fallback for non-nil src shorter
  than 4 bytes.

Verification: stash-revert the production code change with the new
tests in place; all three regression tests fail with exactly the
expected mid-truncated outputs. Restore the fix; all 68 reservations
tests pass and all 620 azure-package tests pass.
cristim added a commit that referenced this pull request May 26, 2026
…eTwoStep (#729)

PR #680 (commit 65ecbf8) switched all 7 Azure reservation executors to
DoPurchaseTwoStep, which mints a fresh server-side reservationOrderId per
calculatePrice call and dropped the deterministic IdempotencyToken
threading PR #653 had added. A re-drive of the same (executionID, recIndex)
would create a SECOND Azure reservation, regressing #641's invariant.

Option A (client-supplied order ID) is not viable: Azure rejects
client-supplied IDs in the two-step flow (the very reason PR #680 had to
adopt it). The fix implements Option B from the issue, mirroring the AWS
EC2 findRIByIdempotencyToken pattern:

  * ApplyPurchaseTags now stamps two tags on every purchase body:
    purchase-automation (attribution, existing) and cudly-idempotency-token
    (new, the deterministic per-rec token).
  * FindReservationOrderByIdempotencyToken lists reservation orders via the
    Microsoft.Capacity REST endpoint, filters by tag, skips terminal-failed
    states (Cancelled/Failed/Expired), and follows nextLink pagination.
  * DoIdempotentPurchaseTwoStep wraps DoPurchaseTwoStep with the
    lookup-first/short-circuit guard. Empty token falls straight through
    (CLI legacy path). Failed lookups refuse to purchase rather than risk
    a double-buy (mirrors EC2 safety contract).

All 7 service executors (cache, compute, cosmosdb, database, managedredis,
search, synapse) now call DoIdempotentPurchaseTwoStep with opts.IdempotencyToken
and ApplyPurchaseTags with both opts.Source and opts.IdempotencyToken.

20 new tests cover both invariants: same token produces one reservation
(short-circuit), different tokens produce distinct reservations.
DoPurchaseTwoStep retains its previous behaviour for callers without an
owning execution. Per-service existing tests continue to pass unchanged.

Closes #721, partial-restore of #641 invariant for Azure, unblocks #639.
cristim added a commit that referenced this pull request May 27, 2026
…rs/purchase/action (#744)

* fix(iac/azure): custom role grants Microsoft.Capacity/reservationOrders/purchase/action

The built-in Reservation Purchaser role lacks
Microsoft.Capacity/reservationOrders/purchase/action, which the two-step
calculatePrice -> purchase flow (PR #680) requires. Replace it with a
custom role that explicitly enumerates the runtime actions used by the
reservation + savings-plan purchase paths, assignable at subscription
scope AND /providers/Microsoft.Capacity.

Fixes 403 AuthorizationFailed on live reservation purchases for newly
onboarded Azure subscriptions.

Closes #731 (if open).

* fix(iac/azure): mirror reservation-purchaser custom role on host SP + add Reader

The host container-app identity was still on the built-in Reservation
Purchaser role (which omits reservationOrders/purchase/action, the same
bug this PR fixes customer-side). Adds the same custom role plus a
subscription-scope Reader, factoring the role definition into a shared
module so customer + host stay in lockstep.

- New module: terraform/modules/iam/azure/cudly-reservation-role
  (main.tf / variables.tf / outputs.tf) - single source of truth for
  the custom role's action list
- iac/federation/azure-target/terraform/main.tf: switch from inlined
  azurerm_role_definition to the shared module (no behavioural change)
- terraform/modules/compute/azure/container-apps/main.tf: replace
  built-in Reservation Purchaser assignment with custom-role module +
  add subscription-scope Reader for host-account resource enumeration

Refs #731.
@cristim
cristim deleted the fix/issue-677-azure-calculate-price branch June 3, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p0 Drop everything; same-day fix severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant