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
1 change: 1 addition & 0 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2425,6 +2425,7 @@ const PAYMENT_DISPLAY_LABELS: Record<string, string> = {
'all-upfront': 'All Upfront',
'partial-upfront': 'Partial Upfront',
'no-upfront': 'No Upfront',
'upfront': 'Upfront',
'monthly': 'Monthly',
};

Expand Down
62 changes: 62 additions & 0 deletions providers/azure/internal/recommendations/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ package recommendations
import (
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/logging"
)

Expand Down Expand Up @@ -246,3 +247,64 @@ func strDeref(s *string) string {
}
return *s
}

// termToMonths maps a normalised term string ("1yr", "3yr") to the number of
// months it spans. Unknown terms default to 12 so the monthly-cost arithmetic
// stays valid rather than dividing by zero.
func termToMonths(term string) int {
switch term {
case "3yr":
return 36
default:
return 12
}
}

// ExpandPaymentVariants fans out a single Azure reservation recommendation
// into two variants that differ only in payment schedule:
//
// - "upfront" — the full reservation cost is paid today; no monthly
// recurring charge (RecurringMonthlyCost = pointer to 0).
// - "monthly" — nothing is paid today; the same total reservation cost
// is spread evenly across the term months (RecurringMonthlyCost =
// CommitmentCost / termMonths).
//
// Azure charges the same total reservation price for both billing plans
// (unlike AWS, which prices partial-upfront separately), so EstimatedSavings
// and SavingsPercentage vs on-demand are identical between the two variants;
// only the cashflow split changes.
//
// The base recommendation must already have PaymentOption set to "upfront"
// and a valid CommitmentCost (total reservation price) and OnDemandCost (total
// on-demand cost over the same period). If OnDemandCost is zero the savings
// fields are forced to zero to avoid a divide-by-zero; if CommitmentCost is
// zero both variants are still emitted with zero costs (caller's responsibility
// to validate upstream).
func ExpandPaymentVariants(base common.Recommendation) []common.Recommendation {
totalReservation := base.CommitmentCost
totalOnDemand := base.OnDemandCost

var savingsPct float64
var savings float64
if totalOnDemand != 0 {
savings = totalOnDemand - totalReservation
savingsPct = savings / totalOnDemand * 100
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
months := termToMonths(base.Term)
recurringMonthly := totalReservation / float64(months)

allUpfront := base
allUpfront.PaymentOption = "upfront"
allUpfront.EstimatedSavings = savings
allUpfront.SavingsPercentage = savingsPct
allUpfront.RecurringMonthlyCost = float64Ptr(0)

noUpfront := base
noUpfront.PaymentOption = "monthly"
noUpfront.EstimatedSavings = savings
noUpfront.SavingsPercentage = savingsPct
noUpfront.RecurringMonthlyCost = float64Ptr(recurringMonthly)

return []common.Recommendation{allUpfront, noUpfront}
}
124 changes: 124 additions & 0 deletions providers/azure/internal/recommendations/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/providers/azure/mocks"
)

Expand Down Expand Up @@ -239,3 +240,126 @@ func TestExtract_Modern_RecurringMonthlyCostIsZeroPointer(t *testing.T) {
require.NotNil(t, f.RecurringMonthlyCost, "RecurringMonthlyCost must be non-nil for Azure reservations")
assert.InDelta(t, 0.0, *f.RecurringMonthlyCost, 1e-9)
}

// --- ExpandPaymentVariants --------------------------------------------------

func baseRec(service common.ServiceType, term string, onDemand, commitment float64) common.Recommendation {
return common.Recommendation{
Provider: common.ProviderAzure,
Service: service,
Region: "eastus",
ResourceType: "Standard_D2s_v3",
CommitmentType: common.CommitmentReservedInstance,
Term: term,
PaymentOption: "upfront",
OnDemandCost: onDemand,
CommitmentCost: commitment,
}
}

func TestExpandPaymentVariants_ReturnsTwoVariants(t *testing.T) {
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 70))
require.Len(t, variants, 2, "must return exactly two variants")
}

func TestExpandPaymentVariants_PaymentOptionValues(t *testing.T) {
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 70))
assert.Equal(t, "upfront", variants[0].PaymentOption)
assert.Equal(t, "monthly", variants[1].PaymentOption)
}

func TestExpandPaymentVariants_AllUpfrontCashflow(t *testing.T) {
// all-upfront: RecurringMonthlyCost must be a non-nil pointer to 0.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 70))
allUpfront := variants[0]
require.NotNil(t, allUpfront.RecurringMonthlyCost)
assert.InDelta(t, 0.0, *allUpfront.RecurringMonthlyCost, 1e-9)
}

func TestExpandPaymentVariants_NoUpfront1yrCashflow(t *testing.T) {
// no-upfront 1yr: RecurringMonthlyCost = CommitmentCost / 12.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 72))
noUpfront := variants[1]
require.NotNil(t, noUpfront.RecurringMonthlyCost)
assert.InDelta(t, 72.0/12.0, *noUpfront.RecurringMonthlyCost, 1e-9)
}

func TestExpandPaymentVariants_NoUpfront3yrCashflow(t *testing.T) {
// no-upfront 3yr: RecurringMonthlyCost = CommitmentCost / 36.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "3yr", 200, 120))
noUpfront := variants[1]
require.NotNil(t, noUpfront.RecurringMonthlyCost)
assert.InDelta(t, 120.0/36.0, *noUpfront.RecurringMonthlyCost, 1e-9)
}

func TestExpandPaymentVariants_SavingsIdenticalAcrossVariants(t *testing.T) {
// EstimatedSavings and SavingsPercentage must be the same for both
// variants — Azure's total reservation price is unchanged between billing
// plans; only cashflow splits.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 70))
assert.InDelta(t, variants[0].EstimatedSavings, variants[1].EstimatedSavings, 1e-9)
assert.InDelta(t, variants[0].SavingsPercentage, variants[1].SavingsPercentage, 1e-9)
}

func TestExpandPaymentVariants_SavingsValues(t *testing.T) {
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 70))
assert.InDelta(t, 30.0, variants[0].EstimatedSavings, 1e-9)
assert.InDelta(t, 30.0, variants[1].EstimatedSavings, 1e-9)
assert.InDelta(t, 30.0, variants[0].SavingsPercentage, 1e-9)
assert.InDelta(t, 30.0, variants[1].SavingsPercentage, 1e-9)
}

func TestExpandPaymentVariants_ZeroOnDemand_NoSavings(t *testing.T) {
// Guard: avoid divide-by-zero when OnDemandCost is 0.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 0, 0))
for _, v := range variants {
assert.InDelta(t, 0.0, v.EstimatedSavings, 1e-9)
assert.InDelta(t, 0.0, v.SavingsPercentage, 1e-9)
}
}

func TestExpandPaymentVariants_ZeroOnDemand_NonZeroCommitment(t *testing.T) {
// Regression: when OnDemandCost == 0 but CommitmentCost > 0, the guard
// must also force EstimatedSavings to 0. Without it, savings would be
// computed as 0 - CommitmentCost and emit negative savings.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 0, 10))
require.Len(t, variants, 2)
for _, v := range variants {
assert.InDelta(t, 0.0, v.EstimatedSavings, 1e-9)
assert.InDelta(t, 0.0, v.SavingsPercentage, 1e-9)
}
}

func TestExpandPaymentVariants_ZeroCommitmentCost(t *testing.T) {
// Zero reservation total: both variants still emitted; no-upfront monthly = 0.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 50, 0))
require.Len(t, variants, 2)
require.NotNil(t, variants[1].RecurringMonthlyCost)
assert.InDelta(t, 0.0, *variants[1].RecurringMonthlyCost, 1e-9)
}

func TestExpandPaymentVariants_SharedFieldsCarriedThrough(t *testing.T) {
// Service, Region, ResourceType, CommitmentType, Term, Account must be
// identical between the two returned variants (only payment schedule changes).
base := baseRec(common.ServiceRelationalDB, "3yr", 200, 140)
base.Account = "sub-123"
variants := ExpandPaymentVariants(base)
for _, v := range variants {
assert.Equal(t, common.ServiceRelationalDB, v.Service)
assert.Equal(t, "eastus", v.Region)
assert.Equal(t, "Standard_D2s_v3", v.ResourceType)
assert.Equal(t, common.CommitmentReservedInstance, v.CommitmentType)
assert.Equal(t, "3yr", v.Term)
assert.Equal(t, "sub-123", v.Account)
}
}

func TestExpandPaymentVariants_RecurringMonthlyCostPointersAreIndependent(t *testing.T) {
// The two variants must hold independent pointer values — mutating one
// must not affect the other.
variants := ExpandPaymentVariants(baseRec(common.ServiceCompute, "1yr", 100, 60))
require.NotNil(t, variants[0].RecurringMonthlyCost)
require.NotNil(t, variants[1].RecurringMonthlyCost)
assert.True(t, variants[0].RecurringMonthlyCost != variants[1].RecurringMonthlyCost,
"each variant must own an independent pointer to its RecurringMonthlyCost")
}
41 changes: 31 additions & 10 deletions providers/azure/recommendations.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/LeanerCloud/CUDly/pkg/common"
"github.com/LeanerCloud/CUDly/pkg/concurrency"
"github.com/LeanerCloud/CUDly/pkg/logging"
azrecs "github.com/LeanerCloud/CUDly/providers/azure/internal/recommendations"
"github.com/LeanerCloud/CUDly/providers/azure/services/cache"
"github.com/LeanerCloud/CUDly/providers/azure/services/compute"
"github.com/LeanerCloud/CUDly/providers/azure/services/cosmosdb"
Expand Down Expand Up @@ -224,21 +225,41 @@ func (r *RecommendationsClientAdapter) getAdvisorRecommendations(ctx context.Con
logging.Warnf("Azure Advisor pagination error (partial results may be returned): %v", err)
break
}
recommendations = r.appendAdvisorPageRecs(params, page, recommendations)
}

for _, advisorRec := range page.Value {
if advisorRec.Properties == nil {
continue
}
return recommendations, nil
}

// Convert Azure Advisor recommendation to our common format
rec := r.convertAdvisorRecommendation(advisorRec)
if rec != nil && shouldIncludeService(params, rec.Service) {
recommendations = append(recommendations, *rec)
// appendAdvisorPageRecs converts one Advisor pager page into common recommendations
// and appends them to the provided slice. Pulled out of getAdvisorRecommendations
// to keep that function under the cyclomatic limit.
func (r *RecommendationsClientAdapter) appendAdvisorPageRecs(
params common.RecommendationParams,
page armadvisor.RecommendationsClientListResponse,
recommendations []common.Recommendation,
) []common.Recommendation {
for _, advisorRec := range page.Value {
if advisorRec.Properties == nil {
continue
}
// Convert Azure Advisor recommendation to our common format
rec := r.convertAdvisorRecommendation(advisorRec)
if rec != nil && shouldIncludeService(params, rec.Service) {
// Preserve Advisor-provided EstimatedSavings when both OnDemandCost
// and CommitmentCost are unset (zero), since ExpandPaymentVariants
// would otherwise overwrite it with zero (OnDemandCost - CommitmentCost).
advisorSavings := rec.EstimatedSavings
variants := azrecs.ExpandPaymentVariants(*rec)
if rec.OnDemandCost == 0 && rec.CommitmentCost == 0 && advisorSavings != 0 {
for i := range variants {
variants[i].EstimatedSavings = advisorSavings
}
}
recommendations = append(recommendations, variants...)
}
}

return recommendations, nil
return recommendations
}

// resolveAdvisorRegion picks the region for an Advisor recommendation.
Expand Down
6 changes: 3 additions & 3 deletions providers/azure/services/cache/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import (
"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"
azrecs "github.com/LeanerCloud/CUDly/providers/azure/internal/recommendations"
"github.com/LeanerCloud/CUDly/providers/azure/services/internal/reservations"
)

Expand Down Expand Up @@ -175,7 +175,7 @@ func (c *CacheClient) GetRecommendations(ctx context.Context, params common.Reco
for _, rec := range page.Value {
converted := c.convertAzureRedisRecommendation(ctx, rec)
if converted != nil {
recommendations = append(recommendations, *converted)
recommendations = append(recommendations, azrecs.ExpandPaymentVariants(*converted)...)
}
}
}
Expand Down Expand Up @@ -574,7 +574,7 @@ func extractRedisPricing(items []CacheRetailPriceItem, termYears int) (onDemand,
// Premium-tier SKU; otherwise stays 0 (zero means "unknown", not
// "definitely zero shards" — see the redisSKUEntry godoc).
func (c *CacheClient) convertAzureRedisRecommendation(ctx context.Context, azureRec armconsumption.ReservationRecommendationClassification) *common.Recommendation {
f := recommendations.Extract(azureRec)
f := azrecs.Extract(azureRec)
if f == nil {
return nil
}
Expand Down
6 changes: 3 additions & 3 deletions providers/azure/services/compute/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
"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"
azrecs "github.com/LeanerCloud/CUDly/providers/azure/internal/recommendations"
"github.com/LeanerCloud/CUDly/providers/azure/services/internal/reservations"
)

Expand Down Expand Up @@ -194,7 +194,7 @@ func (c *ComputeClient) GetRecommendations(ctx context.Context, params common.Re
for _, rec := range page.Value {
converted := c.convertAzureVMRecommendation(ctx, rec)
if converted != nil {
recommendations = append(recommendations, *converted)
recommendations = append(recommendations, azrecs.ExpandPaymentVariants(*converted)...)
}
}
}
Expand Down Expand Up @@ -698,7 +698,7 @@ func extractVMPricing(items []AzureRetailPriceItem, termYears int) (onDemand, re
// sources (consumption usage records, dedicated-host inventory) and
// remain unpopulated — out of scope for this issue.
func (c *ComputeClient) convertAzureVMRecommendation(ctx context.Context, azureRec armconsumption.ReservationRecommendationClassification) *common.Recommendation {
f := recommendations.Extract(azureRec)
f := azrecs.Extract(azureRec)
if f == nil {
return nil
}
Expand Down
54 changes: 54 additions & 0 deletions providers/azure/services/compute/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v5"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/consumption/armconsumption"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -218,6 +219,59 @@ func TestComputeClient_GetRecommendations_WithMock(t *testing.T) {
assert.Empty(t, recommendations)
}

// TestComputeClient_GetRecommendations_EmitsBothPaymentVariants asserts that
// a single Azure reservation recommendation from the API is expanded into two
// entries — "upfront" and "monthly" — with correct cashflow split and
// identical savings figures.
func TestComputeClient_GetRecommendations_EmitsBothPaymentVariants(t *testing.T) {
ctx := context.Background()
client := NewClient(nil, "test-subscription", "eastus")

// Inject a single recommendation via the mock pager.
apiRec := mocks.BuildLegacyReservationRecommendation(
mocks.WithRegion("eastus"),
mocks.WithScope("Shared"),
mocks.WithTerm("P1Y"),
mocks.WithQuantity(1),
mocks.WithNormalizedSize("Standard_D2s_v3"),
mocks.WithCosts(120, 84, 36), // onDemand=120, commitment=84, savings=36
)
mockPager := &mocks.MockRecommendationsPager{
Results: []armconsumption.ReservationRecommendationClassification{apiRec},
HasMore: true,
}
client.SetRecommendationsPager(mockPager)

recs, err := client.GetRecommendations(ctx, common.RecommendationParams{})
require.NoError(t, err)
require.Len(t, recs, 2, "one API rec must expand to two payment-variant entries")

payments := make(map[string]common.Recommendation)
for _, r := range recs {
payments[r.PaymentOption] = r
}
require.Contains(t, payments, "upfront", "upfront variant must be present")
require.Contains(t, payments, "monthly", "monthly variant must be present")

allUp := payments["upfront"]
noUp := payments["monthly"]

// Cashflow: upfront has zero recurring; monthly spreads over 12 months.
require.NotNil(t, allUp.RecurringMonthlyCost)
assert.InDelta(t, 0.0, *allUp.RecurringMonthlyCost, 1e-9)
require.NotNil(t, noUp.RecurringMonthlyCost)
assert.InDelta(t, 84.0/12.0, *noUp.RecurringMonthlyCost, 1e-9)

// Savings must be identical across variants.
assert.InDelta(t, allUp.EstimatedSavings, noUp.EstimatedSavings, 1e-9)
assert.InDelta(t, allUp.SavingsPercentage, noUp.SavingsPercentage, 1e-9)

// Shared fields.
assert.Equal(t, "test-subscription", allUp.Account)
assert.Equal(t, common.ServiceCompute, allUp.Service)
assert.Equal(t, "1yr", allUp.Term)
}

func TestComputeClient_GetExistingCommitments_WithMock(t *testing.T) {
ctx := context.Background()
client := NewClient(nil, "test-subscription", "eastus")
Expand Down
Loading
Loading