Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 15 additions & 30 deletions providers/azure/services/cache/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
Expand All @@ -17,13 +16,13 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/redis/armredis/v3"
"github.com/google/uuid"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/logging"
"github.com/LeanerCloud/CUDly/providers/azure/internal/httpclient"
"github.com/LeanerCloud/CUDly/providers/azure/internal/pricing"
"github.com/LeanerCloud/CUDly/providers/azure/internal/recommendations"
"github.com/LeanerCloud/CUDly/providers/azure/services/internal/reservations"
)

// redisSKUEntry holds the SKU-catalogue-derived fields the converter
Expand Down Expand Up @@ -264,7 +263,8 @@ func (c *CacheClient) convertRedisReservation(detail *armconsumption.Reservation
return commitment
}

// PurchaseCommitment purchases Redis Cache reserved capacity via Azure Reservations API
// PurchaseCommitment purchases Redis Cache reserved capacity using the two-step
// calculatePrice->purchase flow required by Azure's Reservations API (issue #677).
func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recommendation, opts common.PurchaseOptions) (common.PurchaseResult, error) {
result := common.PurchaseResult{
Recommendation: rec,
Expand All @@ -273,13 +273,16 @@ func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recomme
Timestamp: time.Now(),
}

// Derive a deterministic reservationOrderID from the idempotency token (issue
// #641) so a re-drive re-PUTs the same idempotent Azure reservation order
// instead of creating a second; fall back to a random GUID otherwise.
reservationOrderID := common.ReservationOrderID(opts.IdempotencyToken, uuid.New().String())
apiVersion := "2022-11-01"
purchaseURL := fmt.Sprintf("https://management.azure.com/providers/Microsoft.Capacity/reservationOrders/%s?api-version=%s",
reservationOrderID, apiVersion)
// Azure's reservation API mints the order ID server-side in calculatePrice,
// so the only stable dedupe signal we control is the purchase-automation tag
// derived from opts.Source. Without a non-empty Source the tag is dropped and
// a re-driven purchase cannot recognise the prior attempt, producing
// duplicate reservations. Fail fast at function entry rather than allowing
// an un-tagged, non-idempotent purchase to hit the cloud.
if opts.Source == "" {
result.Error = fmt.Errorf("purchase source is required for idempotent Azure reservation purchases")
return result, result.Error
}

termYears := 1
if rec.Term == "3yr" || rec.Term == "3" {
Expand Down Expand Up @@ -309,12 +312,6 @@ func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recomme
return result, result.Error
}

req, err := http.NewRequestWithContext(ctx, "PUT", purchaseURL, strings.NewReader(string(bodyBytes)))
if err != nil {
result.Error = fmt.Errorf("failed to create request: %w", err)
return result, result.Error
}

token, err := c.cred.GetToken(ctx, policy.TokenRequestOptions{
Scopes: []string{"https://management.azure.com/.default"},
})
Expand All @@ -323,27 +320,15 @@ func (c *CacheClient) PurchaseCommitment(ctx context.Context, rec common.Recomme
return result, result.Error
}

req.Header.Set("Authorization", "Bearer "+token.Token)
req.Header.Set("Content-Type", "application/json")

resp, err := c.httpClient.Do(req)
reservationOrderID, err := reservations.DoPurchaseTwoStep(ctx, c.httpClient, reservations.CalculatePriceURL(), bodyBytes, token.Token)
if err != nil {
result.Error = fmt.Errorf("failed to purchase reservation: %w", err)
return result, result.Error
}
defer resp.Body.Close()

body, _ := io.ReadAll(resp.Body)

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusAccepted {
result.Error = fmt.Errorf("reservation purchase failed with status %d: %s", resp.StatusCode, string(body))
result.Error = err
return result, result.Error
}

result.Success = true
result.CommitmentID = reservationOrderID
result.Cost = rec.CommitmentCost

return result, nil
}

Expand Down
133 changes: 108 additions & 25 deletions providers/azure/services/cache/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cache
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
Expand Down Expand Up @@ -916,16 +917,23 @@ func (m *MockTokenCredential) GetToken(ctx context.Context, options policy.Token
}, nil
}

// calcPriceRespJSON returns a minimal calculatePrice response JSON for tests.
func calcPriceRespJSON(orderID string) string {
return `{"properties":{"reservationOrderId":"` + orderID + `"}}`
}

func TestCacheClient_PurchaseCommitment_Success(t *testing.T) {
ctx := context.Background()
mockHTTP := &MockHTTPClient{}
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

mockHTTP.On("Do", mock.Anything).Return(
createMockHTTPResponse(http.StatusOK, `{"id": "reservation-123"}`),
nil,
)
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice"
})).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON("cache-order-001")), nil).Once()
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/cache-order-001/purchase"
})).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once()

rec := common.Recommendation{
ResourceType: "Premium_P1",
Expand All @@ -934,11 +942,12 @@ func TestCacheClient_PurchaseCommitment_Success(t *testing.T) {
CommitmentCost: 1000.0,
}

result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI})
require.NoError(t, err)
assert.True(t, result.Success)
assert.NotEmpty(t, result.CommitmentID)
assert.Equal(t, "cache-order-001", result.CommitmentID)
assert.Equal(t, 1000.0, result.Cost)
mockHTTP.AssertExpectations(t)
}

func TestCacheClient_PurchaseCommitment_3YearTerm(t *testing.T) {
Expand All @@ -947,10 +956,12 @@ func TestCacheClient_PurchaseCommitment_3YearTerm(t *testing.T) {
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

mockHTTP.On("Do", mock.Anything).Return(
createMockHTTPResponse(http.StatusCreated, `{"id": "reservation-123"}`),
nil,
)
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice"
})).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON("cache-order-3yr")), nil).Once()
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/cache-order-3yr/purchase"
})).Return(createMockHTTPResponse(http.StatusCreated, `{}`), nil).Once()

rec := common.Recommendation{
ResourceType: "Premium_P1",
Expand All @@ -959,9 +970,11 @@ func TestCacheClient_PurchaseCommitment_3YearTerm(t *testing.T) {
CommitmentCost: 2500.0,
}

result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI})
require.NoError(t, err)
assert.True(t, result.Success)
assert.Equal(t, "cache-order-3yr", result.CommitmentID)
mockHTTP.AssertExpectations(t)
}

func TestCacheClient_PurchaseCommitment_Accepted(t *testing.T) {
Expand All @@ -970,10 +983,12 @@ func TestCacheClient_PurchaseCommitment_Accepted(t *testing.T) {
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

mockHTTP.On("Do", mock.Anything).Return(
createMockHTTPResponse(http.StatusAccepted, `{"id": "reservation-123"}`),
nil,
)
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice"
})).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON("cache-order-202")), nil).Once()
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/cache-order-202/purchase"
})).Return(createMockHTTPResponse(http.StatusAccepted, `{}`), nil).Once()

rec := common.Recommendation{
ResourceType: "Premium_P1",
Expand All @@ -982,9 +997,10 @@ func TestCacheClient_PurchaseCommitment_Accepted(t *testing.T) {
CommitmentCost: 1000.0,
}

result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI})
require.NoError(t, err)
assert.True(t, result.Success)
mockHTTP.AssertExpectations(t)
}

func TestCacheClient_PurchaseCommitment_TokenError(t *testing.T) {
Expand All @@ -998,7 +1014,7 @@ func TestCacheClient_PurchaseCommitment_TokenError(t *testing.T) {
Term: "1yr",
}

result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI})
require.Error(t, err)
assert.False(t, result.Success)
assert.Contains(t, err.Error(), "failed to get access token")
Expand All @@ -1010,17 +1026,19 @@ func TestCacheClient_PurchaseCommitment_HTTPError(t *testing.T) {
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

mockHTTP.On("Do", mock.Anything).Return(nil, errors.New("network error"))
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice"
})).Return(nil, errors.New("network error")).Once()

rec := common.Recommendation{
ResourceType: "Premium_P1",
Term: "1yr",
}

result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI})
require.Error(t, err)
assert.False(t, result.Success)
assert.Contains(t, err.Error(), "failed to purchase reservation")
assert.Contains(t, err.Error(), "calculatePrice HTTP call")
}

func TestCacheClient_PurchaseCommitment_BadStatus(t *testing.T) {
Expand All @@ -1029,18 +1047,83 @@ func TestCacheClient_PurchaseCommitment_BadStatus(t *testing.T) {
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

mockHTTP.On("Do", mock.Anything).Return(
createMockHTTPResponse(http.StatusBadRequest, `{"error": "invalid request"}`),
nil,
)
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/calculatePrice"
})).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON("cache-order-bad")), nil).Once()
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/cache-order-bad/purchase"
})).Return(createMockHTTPResponse(http.StatusBadRequest, `{"error": "invalid request"}`), nil).Once()

rec := common.Recommendation{
ResourceType: "Premium_P1",
Term: "1yr",
}

result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: common.PurchaseSourceCLI})
require.Error(t, err)
assert.False(t, result.Success)
assert.Contains(t, err.Error(), "reservation purchase failed with status 400")
mockHTTP.AssertExpectations(t)
}

// TestCacheClient_PurchaseCommitment_TagInjection verifies that the
// purchase-automation tag carrying opts.Source is present in the
// calculatePrice request body. Without this regression test the dedupe
// guard introduced for the Azure two-step flow could regress silently:
// the call would succeed without the tag and re-driven purchases would
// duplicate reservations server-side.
func TestCacheClient_PurchaseCommitment_TagInjection(t *testing.T) {
const orderID = "cache-tag-test"
const source = common.PurchaseSourceWeb

ctx := context.Background()
mockHTTP := &MockHTTPClient{}
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

var capturedBody []byte
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
if r.URL.Path != "/providers/Microsoft.Capacity/calculatePrice" {
return false
}
capturedBody, _ = io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewReader(capturedBody))
return true
})).Return(createMockHTTPResponse(http.StatusOK, calcPriceRespJSON(orderID)), nil).Once()
mockHTTP.On("Do", mock.MatchedBy(func(r *http.Request) bool {
return r.URL.Path == "/providers/Microsoft.Capacity/reservationOrders/"+orderID+"/purchase"
})).Return(createMockHTTPResponse(http.StatusOK, `{}`), nil).Once()

rec := common.Recommendation{ResourceType: "Premium_P1", Term: "1yr", Count: 1, CommitmentCost: 1000.0}
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{Source: source})
require.NoError(t, err)
assert.True(t, result.Success)

var body map[string]interface{}
require.NoError(t, json.Unmarshal(capturedBody, &body))
tags, hasTags := body["tags"].(map[string]interface{})
require.True(t, hasTags, "tags field must be present in calculatePrice body when Source is set")
assert.Equal(t, source, tags[common.PurchaseTagKey], "tag value must match opts.Source")
mockHTTP.AssertExpectations(t)
}

// TestCacheClient_PurchaseCommitment_RequiresSource pins the dedupe guard:
// PurchaseCommitment must reject an empty opts.Source before issuing any HTTP
// call. Azure mints the reservation order ID server-side, so the
// purchase-automation tag derived from Source is the only stable dedupe
// signal CUDly controls -- proceeding without it would allow a re-driven
// purchase to create a duplicate reservation.
func TestCacheClient_PurchaseCommitment_RequiresSource(t *testing.T) {
ctx := context.Background()
mockHTTP := &MockHTTPClient{}
mockCred := &MockTokenCredential{token: "test-token"}
client := NewClientWithHTTP(mockCred, "test-subscription", "eastus", mockHTTP)

rec := common.Recommendation{ResourceType: "Premium_P1", Term: "1yr", Count: 1}
result, err := client.PurchaseCommitment(ctx, rec, common.PurchaseOptions{})
require.Error(t, err)
assert.False(t, result.Success)
assert.Contains(t, err.Error(), "purchase source is required")
// No HTTP call may be issued when the guard rejects the request.
mockHTTP.AssertNotCalled(t, "Do", mock.Anything)
}
Loading
Loading