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
2 changes: 1 addition & 1 deletion providers/aws/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/LeanerCloud/cloud-commitments-go/providers/aws
go 1.26.9

require (
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015071854-5b254484d5e4
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015085730-498b74f31f01
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.29.12
github.com/aws/aws-sdk-go-v2/credentials v1.17.65
Expand Down
4 changes: 2 additions & 2 deletions providers/aws/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015071854-5b254484d5e4 h1:qI0IihoIaQ0MFgj9hXF0aGyokniwwYS938PrNmMVbGs=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015071854-5b254484d5e4/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015085730-498b74f31f01 h1:R2EQAvwLAxPrdlZXmF+bI7ioQYh1CxBkethx5hq6WWA=
github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261015085730-498b74f31f01/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.29.12 h1:Y/2a+jLPrPbHpFkpAAYkVEtJmxORlXoo5k2g1fa2sUo=
Expand Down
265 changes: 265 additions & 0 deletions providers/aws/recommendations/commitment_currency_sdk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
package recommendations_test

import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strings"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations"
)

type currencySDKFixture struct {
t *testing.T
detail map[string]any
payment, term string
sp bool
calls int
}

func (f *currencySDKFixture) Do(req *http.Request) (*http.Response, error) {
if req.URL.Scheme != "https" || req.URL.Host != "ce.us-east-1.amazonaws.com" || req.Method != http.MethodPost {
return nil, fmt.Errorf("currency fixture rejects request %s %s", req.Method, req.URL)
}
operation := "GetReservationPurchaseRecommendation"
if f.sp {
operation = "GetSavingsPlansPurchaseRecommendation"
}
if req.Header.Get("X-Amz-Target") != "AWSInsightsIndexService."+operation {
return nil, fmt.Errorf("currency fixture rejects operation %s", req.Header.Get("X-Amz-Target"))
}
f.calls++
var input map[string]any
if err := json.NewDecoder(req.Body).Decode(&input); err != nil {
return nil, err
}
payment := map[string]string{"all-upfront": "ALL_UPFRONT", "partial-upfront": "PARTIAL_UPFRONT", "no-upfront": "NO_UPFRONT"}[f.payment]
if payment == "" {
assert.Empty(f.t, input["PaymentOption"])
} else {
assert.Equal(f.t, payment, input["PaymentOption"])
}
assert.Equal(f.t, map[string]string{"1yr": "ONE_YEAR", "3yr": "THREE_YEARS"}[f.term], input["TermInYears"])
assert.Equal(f.t, "SEVEN_DAYS", input["LookbackPeriodInDays"])
assert.Equal(f.t, "LINKED", input["AccountScope"])
var response map[string]any
if f.sp {
assert.Equal(f.t, "COMPUTE_SP", input["SavingsPlansType"])
response = map[string]any{"SavingsPlansPurchaseRecommendation": map[string]any{
"SavingsPlansPurchaseRecommendationDetails": []any{f.detail}}}
} else {
assert.Equal(f.t, "Amazon Relational Database Service", input["Service"])
response = map[string]any{"Recommendations": []any{map[string]any{"RecommendationDetails": []any{f.detail}}}}
}
body, err := json.Marshal(response)
if err != nil {
return nil, err
}
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/x-amz-json-1.1"}},
Body: io.NopCloser(strings.NewReader(string(body))), Request: req}, nil
}

// The CE JSON is constructed; it is not a recorded account or billing response.
func currencySDKRecommendation(t *testing.T, sp bool, payment, term string, code, upfront, recurring *string) ([]common.Recommendation, error) {
t.Helper()
detail := map[string]any{"AccountId": "fixture-account", "EstimatedMonthlySavingsAmount": "20",
"CurrentAverageHourlyOnDemandSpend": "4", "EstimatedMonthlyOnDemandCost": "100"}
if code != nil {
detail["CurrencyCode"] = *code
}
if upfront != nil {
detail["UpfrontCost"] = *upfront
}
service, recurringField := common.ServiceRDS, "RecurringStandardMonthlyCost"
if sp {
service, recurringField = common.ServiceSavingsPlansCompute, "HourlyCommitmentToPurchase"
} else {
detail["RecommendedNumberOfInstancesToPurchase"] = "2"
detail["InstanceDetails"] = map[string]any{"RDSInstanceDetails": map[string]any{
"InstanceType": "db.m5.large", "DatabaseEngine": "MySQL", "Region": "us-east-1"}}
}
if recurring != nil {
detail[recurringField] = *recurring
}
fixture := &currencySDKFixture{t: t, detail: detail, sp: sp, payment: payment, term: term}
client := recommendations.NewClient(&aws.Config{Region: "us-east-1", HTTPClient: fixture, RetryMaxAttempts: 1,
Credentials: aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) {
return aws.Credentials{AccessKeyID: "synthetic", SecretAccessKey: "synthetic"}, nil
})})
got, err := client.GetRecommendations(context.Background(), &common.RecommendationParams{
Service: service, Term: term, PaymentOption: payment, LookbackPeriod: "7d"})
wantCalls := 1
if sp && payment == "unknown" {
wantCalls = 0
}
assert.Equal(t, wantCalls, fixture.calls)
return got, err
}

func TestCommitmentCurrencySDKRequiredLegs(t *testing.T) {
t.Parallel()
for _, sp := range []bool{false, true} {
for _, tc := range []struct {
name, payment string
code, upfront, recurring *string
want string
}{
{"partial-EUR", "partial-upfront", aws.String("EUR"), aws.String("120"), aws.String("10"), "EUR"},
{"partial-JPY", "partial-upfront", aws.String("JPY"), aws.String("120"), aws.String("10"), "JPY"},
{"currency-absent", "partial-upfront", nil, aws.String("120"), aws.String("10"), ""},
{"currency-empty", "partial-upfront", aws.String(""), aws.String("120"), aws.String("10"), ""},
{"currency-blank", "partial-upfront", aws.String(" \t"), aws.String("120"), aws.String("10"), ""},
{"partial-missing-upfront", "partial-upfront", aws.String("EUR"), nil, aws.String("10"), ""},
{"partial-missing-recurring", "partial-upfront", aws.String("EUR"), aws.String("120"), nil, ""},
{"partial-explicit-zero", "partial-upfront", aws.String("EUR"), aws.String("0"), aws.String("0"), "EUR"},
{"all-missing-upfront", "all-upfront", aws.String("EUR"), nil, nil, ""},
{"all-contract-zero", "all-upfront", aws.String("EUR"), aws.String("120"), nil, "EUR"},
{"all-explicit-zero", "all-upfront", aws.String("EUR"), aws.String("120"), aws.String("0"), "EUR"},
{"no-contract-zero", "no-upfront", aws.String("EUR"), nil, aws.String("10"), "EUR"},
{"no-explicit-zero", "no-upfront", aws.String("EUR"), aws.String("0"), aws.String("0"), "EUR"},
{"no-missing-recurring", "no-upfront", aws.String("EUR"), nil, nil, ""},
{"no-contradictory-upfront", "no-upfront", aws.String("EUR"), aws.String("120"), aws.String("10"), ""},
{"unknown-payment", "unknown", aws.String("EUR"), aws.String("120"), aws.String("10"), ""},
} {
t.Run(fmt.Sprintf("sp=%t/%s", sp, tc.name), func(t *testing.T) {
got, err := currencySDKRecommendation(t, sp, tc.payment, "1yr", tc.code, tc.upfront, tc.recurring)
if sp && tc.payment == "unknown" {
require.Error(t, err)
assert.Empty(t, got)
return
}
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, tc.want, got[0].CommitmentCurrency)
assertCurrencyJSON(t, got[0], sp)
if tc.recurring == nil {
assert.Nil(t, got[0].RecurringMonthlyCost)
}
})
}
}
}

func TestCommitmentCurrencySDKContradictions(t *testing.T) {
t.Parallel()
got, err := currencySDKRecommendation(t, false, "all-upfront", "1yr", aws.String("EUR"), aws.String("120"), aws.String("10"))
require.NoError(t, err)
require.Len(t, got, 1)
assert.Empty(t, got[0].CommitmentCurrency)
assert.Equal(t, 120.0, got[0].CommitmentCost)
require.NotNil(t, got[0].RecurringMonthlyCost)
assert.Equal(t, 10.0, *got[0].RecurringMonthlyCost)
got, err = currencySDKRecommendation(t, true, "no-upfront", "1yr", aws.String("EUR"), nil, aws.String("1e308"))
require.NoError(t, err)
require.Len(t, got, 1)
assert.Empty(t, got[0].CommitmentCurrency)
require.NotNil(t, got[0].RecurringMonthlyCost)
assert.True(t, math.IsInf(*got[0].RecurringMonthlyCost, 1))
}

func TestCommitmentCurrencySDKMalformedAmounts(t *testing.T) {
t.Parallel()
for _, sp := range []bool{false, true} {
for _, value := range []string{"", "bad", "-1", "NaN", "+Inf"} {
for _, upfront := range []bool{false, true} {
t.Run(fmt.Sprintf("sp=%t/upfront=%t/%q", sp, upfront, value), func(t *testing.T) {
up, recurring := aws.String("120"), aws.String("10")
if upfront {
up = aws.String(value)
} else {
recurring = aws.String(value)
}
got, err := currencySDKRecommendation(t, sp, "partial-upfront", "1yr", aws.String("EUR"), up, recurring)
require.Error(t, err)
assert.Empty(t, got)
})
}
}
}
}

func TestCommitmentCurrencySDKAmountsScaleAndJSON(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name, payment, term, upfront, recurring string
sp bool
wantUpfront, wantMonthly float64
}{
{"RI-partial", "partial-upfront", "1yr", "120", "10", false, 120, 10},
{"SP-partial-1yr", "partial-upfront", "1yr", "4380", "1", true, 4380, 365},
{"SP-partial-3yr", "partial-upfront", "3yr", "13140", "1", true, 13140, 365},
{"SP-no-upfront", "no-upfront", "1yr", "0", "1", true, 0, 730},
{"SP-all-upfront", "all-upfront", "1yr", "8760", "1", true, 8760, 0},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := currencySDKRecommendation(t, tc.sp, tc.payment, tc.term, aws.String("EUR"), aws.String(tc.upfront), aws.String(tc.recurring))
require.NoError(t, err)
require.Len(t, got, 1)
rec := got[0]
require.Equal(t, "EUR", rec.CommitmentCurrency)
assert.Equal(t, tc.wantUpfront, rec.CommitmentCost)
require.NotNil(t, rec.RecurringMonthlyCost)
assert.Equal(t, tc.wantMonthly, *rec.RecurringMonthlyCost)
scaled := common.ScaleRecommendationCosts(rec, 0.5)
assert.Equal(t, rec.CommitmentCost/2, scaled.CommitmentCost)
assert.Equal(t, tc.wantMonthly/2, *scaled.RecurringMonthlyCost)
assert.NotSame(t, rec.RecurringMonthlyCost, scaled.RecurringMonthlyCost)
assert.Equal(t, 20.0, rec.EstimatedSavings)
assert.Equal(t, 10.0, scaled.EstimatedSavings)
if tc.sp {
assert.Equal(t, 1, rec.Count)
assert.Equal(t, 2920.0, rec.OnDemandCost)
} else {
assert.Equal(t, 100.0, rec.OnDemandCost)
}
if !tc.sp {
assert.Equal(t, 2, rec.Count)
resized, err := common.ResizeCount(rec, 1)
require.NoError(t, err)
assert.Equal(t, 1, resized.Count)
assert.Equal(t, 60.0, resized.CommitmentCost)
assert.Equal(t, 5.0, *resized.RecurringMonthlyCost)
assert.Equal(t, "EUR", resized.CommitmentCurrency)
}
assert.Equal(t, "EUR", scaled.CommitmentCurrency)
assert.Equal(t, tc.wantMonthly, *rec.RecurringMonthlyCost)
if tc.sp {
original := rec.Details.(*common.SavingsPlanDetails)
assert.Equal(t, 1.0, original.HourlyCommitment)
assert.Equal(t, 0.5, scaled.Details.(*common.SavingsPlanDetails).HourlyCommitment)
assert.Empty(t, original.Currency)
}
assertCurrencyJSON(t, scaled, tc.sp)
})
}
}

func assertCurrencyJSON(t *testing.T, rec common.Recommendation, sp bool) {
t.Helper()
payload, err := json.Marshal(rec)
require.NoError(t, err)
var fields map[string]any
require.NoError(t, json.Unmarshal(payload, &fields))
if rec.CommitmentCurrency == "" {
assert.NotContains(t, fields, "commitment_currency")
} else {
assert.Equal(t, rec.CommitmentCurrency, fields["commitment_currency"])
}
decoded := common.Recommendation{Details: &common.DatabaseDetails{}}
if sp {
decoded.Details = &common.SavingsPlanDetails{}
}
require.NoError(t, json.Unmarshal(payload, &decoded))
assert.Equal(t, rec.CommitmentCurrency, decoded.CommitmentCurrency)
assert.Equal(t, rec.CommitmentCost, decoded.CommitmentCost)
assert.Equal(t, rec.RecurringMonthlyCost, decoded.RecurringMonthlyCost)
}
23 changes: 23 additions & 0 deletions providers/aws/recommendations/parser_ri.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log"
"math"
"strconv"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
Expand Down Expand Up @@ -223,9 +224,31 @@ func (c *Client) parseAWSCostDetails(rec *common.Recommendation, details *types.
}
rec.RecurringMonthlyCost = &monthly
}
currency := aws.ToString(details.CurrencyCode)
if strings.TrimSpace(currency) == "" {
currency = ""
}
rec.CommitmentCurrency = commitmentCurrency(currency, rec.PaymentOption, details.UpfrontCost != nil, rec.CommitmentCost, rec.RecurringMonthlyCost)
return nil
}

func commitmentCurrency(currency, payment string, hasUpfront bool, upfront float64, monthly *float64) string {
monthlyKnown := monthly != nil && math.Abs(*monthly) <= math.MaxFloat64
var complete bool
switch payment {
case "all-upfront":
complete = hasUpfront && (monthly == nil || *monthly == 0)
case "partial-upfront":
complete = hasUpfront && monthlyKnown
case "no-upfront":
complete = upfront == 0 && monthlyKnown
}
if complete {
return currency
}
return ""
}

// serviceParserFunc defines the signature for service-specific parsers.
type serviceParserFunc func(context.Context, *common.Recommendation, *types.ReservationPurchaseRecommendationDetail) error

Expand Down
5 changes: 5 additions & 0 deletions providers/aws/recommendations/parser_sp.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ func (c *Client) parseSavingsPlanDetail(
}
recurringMonthlyCost = &monthly
}
currency := aws.ToString(detail.CurrencyCode)
if strings.TrimSpace(currency) == "" {
currency = ""
}

return &common.Recommendation{
Provider: common.ProviderAWS,
Expand All @@ -261,6 +265,7 @@ func (c *Client) parseSavingsPlanDetail(
EstimatedSavings: monthlySavings,
SavingsPercentage: savingsPercent,
CommitmentCost: upfrontCost,
CommitmentCurrency: commitmentCurrency(currency, params.PaymentOption, detail.UpfrontCost != nil, upfrontCost, recurringMonthlyCost),
OnDemandCost: onDemandCost,
RecurringMonthlyCost: recurringMonthlyCost,
RecommendedUtilization: recommendedUtilization,
Expand Down
Loading