Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
417ecf0
refactor(azure): share strict reservation inventory listing
cristim Nov 7, 2026
7e19112
fix(azure): read Cosmos DB held reservations from the Reservations API
cristim Nov 7, 2026
bfb0594
test(azure): cap Cosmos DB reservation inventory pagination
cristim Nov 7, 2026
23fc4b4
test(azure): tighten strict inventory helper tests
cristim Nov 7, 2026
c2238a2
test(azure): prove held Cosmos DB reservations dedupe recommendations
cristim Nov 7, 2026
65c99db
fix(azure): read Synapse held reservations from the Reservations API
cristim Nov 7, 2026
b3121a7
refactor(azure): drop the unused Synapse Consumption reservation conv…
cristim Nov 7, 2026
752628a
test(azure): cap Synapse reservation inventory pagination
cristim Nov 7, 2026
0201862
test(azure): prove held Synapse reservations dedupe recommendations
cristim Nov 7, 2026
42b1413
fix(azure): update Cosmos DB inventory error expectations and changelog
cristim Nov 7, 2026
584b987
fix(azure): read SQL Database held reservations from the Reservations…
cristim Nov 7, 2026
7bb1eaf
refactor(azure): drop the unused SQL Database Consumption reservation…
cristim Nov 7, 2026
4805d90
test(azure): cover SQL Database reservation inventory errors and pagi…
cristim Nov 7, 2026
c55bacf
test(azure): prove held SQL Database reservations dedupe recommendations
cristim Nov 7, 2026
c516020
test(azure): share reservation cap-test pagers and pin purchase region
cristim Nov 7, 2026
cfc64fc
test(azure): tighten SQL Database inventory tests and Deployment doc
cristim Nov 7, 2026
093bce1
test(azure): make the SQL fleet walk-failure case actually fail the walk
cristim Nov 7, 2026
125cbb0
style(azure): align reservation test fixture structs
cristim Nov 7, 2026
25d083b
fix(azure): return cancellation from SQL Database inventory enrichment
cristim Nov 7, 2026
c7d5ac5
fix(azure): stamp SQL Database held reservations with the recommendat…
cristim Nov 7, 2026
6a34025
style(azure): align SQL Database client struct fields
cristim Nov 7, 2026
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

- **Breaking (source):** `managedredis.ReservationsDetailsPager` and `(*managedredis.Client).SetReservationsPager` are removed; tests inject reservation inventory with `SetInventoryFactories` instead (#699).
- **`azure` Redis:** `managedredis.GetExistingCommitments` now reads held reservations from the Reservations API (purchase date, quantity, location, state), so a Redis reservation bought in the last day reduces the matching recommendation instead of being invisible to the duplicate guard (#699). An incomplete live reservation row (missing SKU, location, quantity or purchase date, an unattributable scope, or an expiry falling today) now fails the lookup with an error instead of being skipped, as `azure` compute already does.
- **Breaking (source):** `cosmosdb.ReservationsDetailsPager` and `(*cosmosdb.Client).SetReservationsPager` are removed; tests inject reservation inventory with `SetInventoryFactories` instead (#705).
- **`azure` Cosmos DB:** `cosmosdb.GetExistingCommitments` now reads held reservations from the Reservations API (purchase date, quantity, location, state), so a Cosmos DB reservation bought in the last day reduces the matching recommendation instead of being invisible to the duplicate guard. `Region` is now the reservation location, not the client region. An incomplete live reservation row fails the lookup with an error instead of being skipped (#705).
- **Breaking (source):** `synapse.ReservationsDetailsPager` and `(*synapse.Client).SetReservationsPager` are removed; tests inject reservation inventory with `SetInventoryFactories` instead (#705).
- **`azure` Synapse:** `synapse.GetExistingCommitments` now reads held reservations from the Reservations API (purchase date, quantity, location, state), so a Synapse reservation bought in the last day reduces the matching recommendation instead of being invisible to the duplicate guard. `Region` is now the reservation location, not the client region. An incomplete live reservation row fails the lookup with an error instead of being skipped (#705).
- **Breaking (source):** `database.ReservationsDetailsPager` and `(*database.Client).SetReservationsPager` are removed; tests inject reservation inventory with `SetInventoryFactories` instead (#705).
- **`azure` SQL Database:** `database.GetExistingCommitments` now reads held reservations from the Reservations API (purchase date, quantity, location, state), so a SQL Database reservation bought in the last day reduces the matching recommendation instead of being invisible to the duplicate guard. Held reservations carry engine `sqlserver` and the subscription's dominant zone-redundancy setting, as recommendations do. `Region` is now the reservation location, not the client region. An incomplete live reservation row fails the lookup with an error instead of being skipped (#705).
- **`aws` RI recommendations:** a fractional Cost Explorer quantity now rounds
down instead of to the nearest integer, so a purchase never exceeds CE's
quantity. The upfront, recurring monthly cost, monthly savings and savings
Expand Down
14 changes: 7 additions & 7 deletions pkg/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,12 @@ const (

// Commitment represents an existing commitment (RI/SP/CUD/etc).
//
// Deployment is RDS-specific (Multi-AZ vs Single-AZ); it stays empty for
// services that don't have a deployment dimension (EC2, ElastiCache,
// etc). When populated, it carries the same vocabulary as
// DatabaseDetails.AZConfig on Recommendation ("single-az" / "multi-az")
// so pool-key matching can collapse both sides via normaliseDeployment
// in the recommendations package. Without this field, RDS expiry
// Deployment is set for AWS RDS and Azure SQL Database and stays empty for
// services without a deployment dimension (EC2, ElastiCache, etc). When
// populated, it carries the same vocabulary as DatabaseDetails.AZConfig on
// Recommendation ("single-az" / "multi-az" on AWS, "zoneRedundant" / "none"
// on Azure) so pool-key matching can collapse both sides via
// normaliseDeployment in the recommendations package. Without this field, RDS expiry
// adjustments would silently miss because Recommendation lookup keys
// are deployment-aware while commitment keys defaulted to empty.
type Commitment struct {
Expand All @@ -546,7 +546,7 @@ type Commitment struct {
Region string `json:"region"`
ResourceType string `json:"resource_type"`
Engine string `json:"engine,omitempty"` // Database engine for RDS/ElastiCache (e.g., "mysql", "aurora-postgresql")
Deployment string `json:"deployment,omitempty"` // RDS Multi-AZ vs Single-AZ; empty for non-RDS
Deployment string `json:"deployment,omitempty"` // RDS Multi-AZ vs Single-AZ, Azure SQL zone redundancy; empty otherwise
State CommitmentState `json:"state"`

// EC2 reserved-instance dimensions. Empty means unknown or a wildcard.
Expand Down
33 changes: 31 additions & 2 deletions providers/azure/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
"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/reservations/armreservations"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -469,8 +470,6 @@ func TestAzureClients_GetExistingCommitments_PagerConstructionError(t *testing.T
client provider.ServiceClient
}{
{"cache", NewCacheClient(nil, "test-subscription", "eastus")},
{"cosmosdb", NewCosmosDBClient(nil, "test-subscription", "eastus")},
{"database", NewDatabaseClient(nil, "test-subscription", "eastus")},
{"search", NewSearchClient(nil, "test-subscription", "eastus")},
} {
t.Run(tc.name, func(t *testing.T) {
Expand All @@ -485,6 +484,36 @@ func TestAzureClients_GetExistingCommitments_PagerConstructionError(t *testing.T
}
}

// Held reservations come from the Reservations API, so a client-construction
// failure of the default inventory factories surfaces under the shared
// "<service>: list applied reservations" prefix.
func TestAzureClients_ReservationInventory_DefaultFactoryError(t *testing.T) {
// Keep this test sequential: the SDK reads this process-wide configuration.
originalCloud := cloud.AzurePublic
t.Cleanup(func() { cloud.AzurePublic = originalCloud })
cloud.AzurePublic = cloud.Configuration{}

_, sdkErr := armreservations.NewAzureReservationAPIClient(nil, nil)
require.Error(t, sdkErr)

for _, tc := range []struct {
name string
client provider.ServiceClient
}{
{"cosmosdb", NewCosmosDBClient(nil, "test-subscription", "eastus")},
{"database", NewDatabaseClient(nil, "test-subscription", "eastus")},
{"synapse", NewSynapseClient(nil, "test-subscription", "eastus")},
} {
t.Run(tc.name, func(t *testing.T) {
commitments, err := tc.client.GetExistingCommitments(context.Background())
require.Error(t, err)
assert.Nil(t, commitments)
assert.Contains(t, err.Error(), tc.name+": list applied reservations: ")
assert.Contains(t, err.Error(), sdkErr.Error())
})
}
}

func TestAzureProvider_GetRecommendationsClient_NotConfigured(t *testing.T) {
t.Parallel()
// Test GetRecommendationsClient when Azure is not configured. Inject a
Expand Down
20 changes: 2 additions & 18 deletions providers/azure/services/compute/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,24 +294,8 @@ func (c *Client) GetExistingCommitments(ctx context.Context) ([]common.Commitmen
factories = *c.inventoryFactories
}

responses, err := reservations.ListAppliedReservations(ctx, c.subscriptionID, factories, maxReservationsPages)
if err != nil {
return nil, fmt.Errorf("compute: list applied reservations: %w", err)
}

now := time.Now()
commitments := make([]common.Commitment, 0, len(responses))
for _, r := range responses {
commitment, err := reservations.StrictCommitment(r, c.subscriptionID, common.ServiceCompute,
armreservations.ReservedResourceTypeVirtualMachines, reservations.StrictLabels{Service: "compute", Resource: "VM"}, now)
if err != nil {
return nil, err
}
if commitment != nil {
commitments = append(commitments, *commitment)
}
}
return commitments, nil
return reservations.StrictCommitments(ctx, c.subscriptionID, factories, maxReservationsPages, common.ServiceCompute,
armreservations.ReservedResourceTypeVirtualMachines, reservations.StrictLabels{Service: "compute", Resource: "VM"})
}

// providerRegistrationState is the JSON shape returned by the ARM providers API.
Expand Down
114 changes: 20 additions & 94 deletions providers/azure/services/cosmosdb/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,6 @@ type RecommendationsPager interface {
NextPage(ctx context.Context) (armconsumption.ReservationRecommendationsClientListResponse, error)
}

// ReservationsDetailsPager interface for reservations details pager (enables mocking).
type ReservationsDetailsPager interface {
More() bool
NextPage(ctx context.Context) (armconsumption.ReservationsDetailsClientListResponse, error)
}

// CosmosAccountsPager interface for Cosmos DB accounts pager (enables mocking).
type CosmosAccountsPager interface {
More() bool
Expand All @@ -70,7 +64,7 @@ type Client struct {
cred azcore.TokenCredential
httpClient HTTPClient
recommendationsPager RecommendationsPager
reservationsPager ReservationsDetailsPager
inventoryFactories *reservations.InventoryFactories
cosmosAccountsPager CosmosAccountsPager
subscriptionID string
region string
Expand Down Expand Up @@ -118,9 +112,18 @@ func (c *Client) SetRecommendationsPager(pager RecommendationsPager) {
c.recommendationsPager = pager
}

// SetReservationsPager sets the reservations pager (for testing).
func (c *Client) SetReservationsPager(pager ReservationsDetailsPager) {
c.reservationsPager = pager
// SetInventoryFactories injects reservation-inventory factories (for testing).
func (c *Client) SetInventoryFactories(f *reservations.InventoryFactories) {
c.inventoryFactories = f
}

// factories returns the injected inventory factories, or the real SDK-backed
// ones when none were injected (production only calls NewClient).
func (c *Client) factories() reservations.InventoryFactories {
if c.inventoryFactories != nil {
return *c.inventoryFactories
}
return reservations.DefaultInventoryFactories(c.cred)
}

// SetCosmosAccountsPager sets the Cosmos DB accounts pager (for testing).
Expand Down Expand Up @@ -212,91 +215,14 @@ func (c *Client) appendPage(ctx context.Context, pricer *azrecs.Pricer, recs []c
return recs, nil
}

// GetExistingCommitments retrieves existing Cosmos DB reserved capacity using Azure Resource Graph.
// GetExistingCommitments retrieves the Cosmos DB reservations that apply to
// this subscription from the Reservations API, which carries the purchase
// date, quantity, location and lifecycle state the duplicate-purchase guard
// needs (issue #705). Any failure or incomplete live row is returned as an
// error rather than as an empty or partial inventory.
func (c *Client) GetExistingCommitments(ctx context.Context) ([]common.Commitment, error) {
pager, err := c.createReservationsPager()
if err != nil {
return nil, fmt.Errorf("cosmosdb: create reservations pager: %w", err)
}

return c.collectCosmosReservations(ctx, pager)
}

// createReservationsPager creates a pager for listing reservations.
func (c *Client) createReservationsPager() (ReservationsDetailsPager, error) {
// Use injected pager if available (for testing)
if c.reservationsPager != nil {
return c.reservationsPager, nil
}

client, err := armconsumption.NewReservationsDetailsClient(c.cred, nil)
if err != nil {
return nil, err
}

scope := fmt.Sprintf("subscriptions/%s", c.subscriptionID)
return client.NewListPager(scope, &armconsumption.ReservationsDetailsClientListOptions{}), nil
}

// collectCosmosReservations collects Cosmos DB reservations from the pager.
// Returns an error on first pagination failure so callers can't silently act
// on a partial list — see the compute client for the full rationale.
func (c *Client) collectCosmosReservations(ctx context.Context, pager ReservationsDetailsPager) ([]common.Commitment, error) {
commitments := make([]common.Commitment, 0)

for pageIdx := 0; pager.More(); pageIdx++ {
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("context canceled during pagination: %w", err)
}
if pageIdx >= maxReservationsPages {
return nil, fmt.Errorf("cosmosdb: GetExistingCommitments pagination cap (%d pages) reached", maxReservationsPages)
}
page, err := slot.NextPage(ctx, pager)
if err != nil {
return nil, fmt.Errorf("cosmosdb: list reservations: %w", err)
}

for _, detail := range page.Value {
if commitment := c.convertCosmosReservation(detail); commitment != nil {
commitments = append(commitments, *commitment)
}
}
}

// ReservationsDetails is a daily usage API: one row per reservation per
// usage day. Without this, a single reservation held for N days would
// surface as N commitments sharing one CommitmentID (issue #73).
return reservations.DedupeCommitmentsByID(commitments), nil
}

// convertCosmosReservation converts a reservation detail to a commitment if it's a Cosmos DB reservation.
func (c *Client) convertCosmosReservation(detail *armconsumption.ReservationDetail) *common.Commitment {
if detail.Properties == nil {
return nil
}

props := detail.Properties
if props.SKUName == nil || !strings.Contains(strings.ToLower(*props.SKUName), "cosmos") {
return nil
}

commitment := &common.Commitment{
Provider: common.ProviderAzure,
Account: c.subscriptionID,
CommitmentType: common.CommitmentReservedInstance,
Service: common.ServiceNoSQL,
Region: c.region,
State: common.CommitmentStateActive,
}

if props.ReservationID != nil {
commitment.CommitmentID = *props.ReservationID
}
if props.SKUName != nil {
commitment.ResourceType = *props.SKUName
}

return commitment
return reservations.StrictCommitments(ctx, c.subscriptionID, c.factories(), maxReservationsPages, common.ServiceNoSQL,
armreservations.ReservedResourceTypeCosmosDb, reservations.StrictLabels{Service: "cosmosdb", Resource: "Cosmos DB"})
}

// PurchaseCommitment purchases Cosmos DB reserved capacity using the two-step
Expand Down
Loading
Loading