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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/common/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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-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
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-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=
Expand Down
7 changes: 6 additions & 1 deletion providers/aws/recommendations/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
61 changes: 61 additions & 0 deletions providers/aws/recommendations/client_ri_account_scope_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 7 additions & 1 deletion providers/aws/recommendations/collection_sp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading