diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c850bcb..cef5b75a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ### Added +- **`RecommendationParams.RIAccountScope`:** selects the Cost Explorer account + scope (`linked` default, or `payer`) for Reserved Instance recommendations, + mirroring `SPAccountScope`. Empty keeps the previous linked behaviour; an + unknown value is rejected (#508). - **`recommendations.RederiveFamilyCoverage`:** recomputes the family-wide RDS `ProjectedCoverage` that family-NU sizing wrote, after a caller changed `Count` (`--max-instances`, count override, duplicate-RI dedupe). It shares diff --git a/pkg/common/types.go b/pkg/common/types.go index ab7ad6bb..489d6fc4 100644 --- a/pkg/common/types.go +++ b/pkg/common/types.go @@ -554,6 +554,10 @@ type RecommendationParams struct { // recommendations: "linked" or "payer" (case-insensitive). Empty means // linked. Payer scope only applies to a management account. SPAccountScope string + // RIAccountScope selects the Cost Explorer account scope for Reserved + // Instance recommendations, with the same values as SPAccountScope: + // "linked" or "payer" (case-insensitive). Empty means linked. + RIAccountScope string // ExperimentalGCPPlanTerm (GCP Compute Engine only, ignored elsewhere) // takes the CUD term from the recommendation's root Commitment plan // instead of Term. EXPERIMENTAL: the payload shape is unverified against a diff --git a/providers/aws/go.mod b/providers/aws/go.mod index 4dabcff9..a1df881b 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-20261018003623-541e05e5fd29 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261019223052-37131464e21b 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 22694c7a..8c9ae9ec 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-20261018003623-541e05e5fd29 h1:QJkoZBHkwjvMGplDb1TcuBy3HdYPxlZ+AyR/7QEU8Hc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261018003623-541e05e5fd29/go.mod h1:SCDEgHG6evjPh0RsoXof+J3aMmGXNFMrwbJIle7rMpQ= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261019223052-37131464e21b h1:0JK0YAYhPL3nOD+UKZZlcCtsvJ3yhjj1JeHZWgAll8g= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261019223052-37131464e21b/go.mod h1:SCDEgHG6evjPh0RsoXof+J3aMmGXNFMrwbJIle7rMpQ= 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/client.go b/providers/aws/recommendations/client.go index 08256220..3007a1a8 100644 --- a/providers/aws/recommendations/client.go +++ b/providers/aws/recommendations/client.go @@ -145,12 +145,17 @@ func (c *Client) GetRecommendations(ctx context.Context, params *common.Recommen return c.getSavingsPlansRecommendations(ctx, params) } + scope, err := convertAccountScope(params.RIAccountScope, "RI") + if err != nil { + return nil, err + } + input := &costexplorer.GetReservationPurchaseRecommendationInput{ Service: aws.String(getServiceStringForCostExplorer(params.Service)), PaymentOption: convertPaymentOption(params.PaymentOption), TermInYears: convertTermInYears(params.Term), LookbackPeriodInDays: convertLookbackPeriod(params.LookbackPeriod), - AccountScope: types.AccountScopeLinked, + AccountScope: scope, } if isEC2Service(params.Service) { diff --git a/providers/aws/recommendations/client_ri_account_scope_test.go b/providers/aws/recommendations/client_ri_account_scope_test.go new file mode 100644 index 00000000..601e89aa --- /dev/null +++ b/providers/aws/recommendations/client_ri_account_scope_test.go @@ -0,0 +1,61 @@ +package recommendations_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations" +) + +type riScopeFixture struct{ scopes []string } + +func (f *riScopeFixture) Do(req *http.Request) (*http.Response, error) { + var input map[string]any + if err := json.NewDecoder(req.Body).Decode(&input); err != nil { + return nil, err + } + scope, _ := input["AccountScope"].(string) + f.scopes = append(f.scopes, scope) + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/x-amz-json-1.1"}}, + Body: io.NopCloser(strings.NewReader(`{"Recommendations":[]}`)), Request: req}, nil +} + +func riScopeClient(f *riScopeFixture) *recommendations.Client { + return recommendations.NewClient(&aws.Config{Region: "us-east-1", HTTPClient: f, RetryMaxAttempts: 1, + Credentials: aws.CredentialsProviderFunc(func(context.Context) (aws.Credentials, error) { + return aws.Credentials{AccessKeyID: "synthetic", SecretAccessKey: "synthetic"}, nil + })}) +} + +func TestRIAccountScope(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() + f := &riScopeFixture{} + _, err := riScopeClient(f).GetRecommendations(context.Background(), &common.RecommendationParams{ + Service: common.ServiceRDS, Term: "1yr", PaymentOption: "no-upfront", LookbackPeriod: "7d", RIAccountScope: tc.in}) + require.NoError(t, err) + require.Equal(t, []string{tc.want}, f.scopes) + }) + } +} + +func TestRIAccountScopeRejectsUnknown(t *testing.T) { + t.Parallel() + f := &riScopeFixture{} + _, err := riScopeClient(f).GetRecommendations(context.Background(), &common.RecommendationParams{ + Service: common.ServiceRDS, Term: "1yr", PaymentOption: "no-upfront", LookbackPeriod: "7d", RIAccountScope: "org"}) + require.ErrorContains(t, err, "invalid RI account scope") + require.Empty(t, f.scopes) +} diff --git a/providers/aws/recommendations/collection_sp.go b/providers/aws/recommendations/collection_sp.go index 96ed4b23..e6981b64 100644 --- a/providers/aws/recommendations/collection_sp.go +++ b/providers/aws/recommendations/collection_sp.go @@ -62,13 +62,19 @@ func (c *Client) getSavingsPlansRecommendations(ctx context.Context, params *com // 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) { + return convertAccountScope(s, "Savings Plans") +} + +// convertAccountScope is shared by the Savings Plans and RI recommendation +// paths so both accept the same values. +func convertAccountScope(s, kind 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) + return "", fmt.Errorf("invalid %s account scope %q: want linked or payer", kind, s) } func (c *Client) fetchSPAllPages(