From 80b9241a4d900efb253001d944e1fdab6ae2090f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 13 Oct 2026 18:58:05 +0200 Subject: [PATCH] feat(aws/recommendations): selectable account scope for Savings Plan recommendations Read RecommendationParams.SPAccountScope (added in #357) when requesting Savings Plan recommendations. Empty and linked request LINKED, payer requests PAYER; any other value is an error, never a silent LINKED fallback. Bumps the pkg pin to cdcdc7f. Closes #356 Co-Authored-By: Claude Opus 5.5 --- providers/aws/go.mod | 2 +- providers/aws/go.sum | 4 +- .../aws/recommendations/collection_sp.go | 20 +++++++++- .../parser_sp_account_scope_test.go | 38 +++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 providers/aws/recommendations/parser_sp_account_scope_test.go diff --git a/providers/aws/go.mod b/providers/aws/go.mod index faff0db6..5ff1a584 100644 --- a/providers/aws/go.mod +++ b/providers/aws/go.mod @@ -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-20261012192523-3838efd56668 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013182117-cdcdc7fbcc32 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 diff --git a/providers/aws/go.sum b/providers/aws/go.sum index d8cae52c..0598ad2d 100644 --- a/providers/aws/go.sum +++ b/providers/aws/go.sum @@ -1,5 +1,5 @@ -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012192523-3838efd56668 h1:/m3hxkSHGd8AJlhBaPTttRDwtwC1ycYH7IbRkRKR9bM= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012192523-3838efd56668/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013182117-cdcdc7fbcc32 h1:IxqJVVAA26JAS9BtKvNFjn2zPN/LuzPFP5y4O417JS8= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013182117-cdcdc7fbcc32/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= diff --git a/providers/aws/recommendations/collection_sp.go b/providers/aws/recommendations/collection_sp.go index faa904a2..96ed4b23 100644 --- a/providers/aws/recommendations/collection_sp.go +++ b/providers/aws/recommendations/collection_sp.go @@ -3,6 +3,7 @@ package recommendations import ( "context" "fmt" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/costexplorer" @@ -32,11 +33,15 @@ func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params *com if err != nil { return nil, fmt.Errorf("invalid lookback period for Savings Plans recommendation: %w", err) } + scope, err := convertSavingsPlansAccountScope(params.SPAccountScope) + if err != nil { + return nil, err + } results := make([]serviceResult, 0, len(planTypes)) for _, planType := range planTypes { input := &costexplorer.GetSavingsPlansPurchaseRecommendationInput{ SavingsPlansType: planType, PaymentOption: payment, TermInYears: term, - LookbackPeriodInDays: lookback, AccountScope: types.AccountScopeLinked, + LookbackPeriodInDays: lookback, AccountScope: scope, } recs, fetchErr := c.fetchSPAllPages(ctx, input, params, planType) if canceled := collectionCancellation(fetchErr); canceled != nil { @@ -53,6 +58,19 @@ func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params *com return mergeServiceResults(results...) } +// convertSavingsPlansAccountScope maps RecommendationParams.SPAccountScope to +// the Cost Explorer scope. Empty is linked, the long-standing default; an +// unknown value is an error rather than a silent fallback. +func convertSavingsPlansAccountScope(s string) (types.AccountScope, error) { + switch strings.ToLower(s) { + case "", "linked": + return types.AccountScopeLinked, nil + case "payer": + return types.AccountScopePayer, nil + } + return "", fmt.Errorf("invalid Savings Plans account scope %q: want linked or payer", s) +} + func (c *Client) fetchSPAllPages( ctx context.Context, input *costexplorer.GetSavingsPlansPurchaseRecommendationInput, diff --git a/providers/aws/recommendations/parser_sp_account_scope_test.go b/providers/aws/recommendations/parser_sp_account_scope_test.go new file mode 100644 index 00000000..fe072943 --- /dev/null +++ b/providers/aws/recommendations/parser_sp_account_scope_test.go @@ -0,0 +1,38 @@ +package recommendations + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" +) + +func TestSPAccountScope(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ in, want string }{ + {"", "LINKED"}, {"linked", "LINKED"}, {"payer", "PAYER"}, {"PAYER", "PAYER"}, + } { + t.Run(tc.in, func(t *testing.T) { + t.Parallel() + fixture := &spSDKFixture{respond: func(spSDKRequest) (map[string]any, error) { return spSDKPage(""), nil }} + params := spSDKParams(common.ServiceSavingsPlansCompute) + params.SPAccountScope = tc.in + _, err := spSDKClient(fixture).GetRecommendations(context.Background(), params) + require.NoError(t, err) + require.Len(t, fixture.requests, 1) + require.Equal(t, tc.want, fixture.requests[0].AccountScope) + }) + } +} + +func TestSPAccountScopeRejectsUnknown(t *testing.T) { + t.Parallel() + fixture := &spSDKFixture{respond: func(spSDKRequest) (map[string]any, error) { return spSDKPage(""), nil }} + params := spSDKParams(common.ServiceSavingsPlansCompute) + params.SPAccountScope = "org" + _, err := spSDKClient(fixture).GetRecommendations(context.Background(), params) + require.ErrorContains(t, err, "invalid Savings Plans account scope") + require.Empty(t, fixture.requests) +}