diff --git a/cmd/main.go b/cmd/main.go index 8d927b4e7..8a25aa9a8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -57,6 +57,7 @@ type Config struct { ExcludeEngines []string ExcludeSPTypes []string IncludeSPTypes []string + SPAccountScope string MaxBreakEvenMonths int TargetCoverage float64 Coverage float64 @@ -133,6 +134,8 @@ func init() { rootCmd.Flags().StringSliceVar(&toolCfg.IncludeSPTypes, "include-sp-types", []string{}, "Only include these Savings Plan types (comma-separated: Compute, EC2Instance, SageMaker, Database)") rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeSPTypes, "exclude-sp-types", []string{}, "Exclude these Savings Plan types (comma-separated: Compute, EC2Instance, SageMaker, Database)") + rootCmd.Flags().StringVar(&toolCfg.SPAccountScope, "sp-account-scope", "linked", "Cost Explorer account scope for Savings Plan recommendations: linked or payer (payer applies to a management account)") + // Purchase pipeline flags rootCmd.Flags().StringVar(&toolCfg.AuditLog, "audit-log", "./cudly-audit.jsonl", "Path to JSONL audit log file") rootCmd.Flags().StringVar(&toolCfg.IdempotencyWindow, "idempotency-window", "24h", "Lookback window for duplicate purchase detection") diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go index b76e4fcd4..112d94d98 100644 --- a/cmd/multi_service_helpers.go +++ b/cmd/multi_service_helpers.go @@ -607,6 +607,7 @@ func fetchRecommendationsForRegion( // Savings Plans specific filters IncludeSPTypes: cfg.IncludeSPTypes, ExcludeSPTypes: cfg.ExcludeSPTypes, + SPAccountScope: cfg.SPAccountScope, } // For Savings Plans, region is only the endpoint the account-level call is // signed in (spAPIRegion), not a filter. The provider folds params.Region diff --git a/cmd/sp_account_scope_test.go b/cmd/sp_account_scope_test.go new file mode 100644 index 000000000..667b514af --- /dev/null +++ b/cmd/sp_account_scope_test.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "testing" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestValidateSPAccountScope(t *testing.T) { + orig := toolCfg.SPAccountScope + t.Cleanup(func() { toolCfg.SPAccountScope = orig }) + for scope, wantErr := range map[string]bool{"": false, "linked": false, "payer": false, "PAYER": false, "org": true} { + toolCfg.SPAccountScope = scope + err := validateSPAccountScope() + if wantErr { + require.Error(t, err, scope) + assert.Contains(t, err.Error(), "invalid sp-account-scope") + } else { + require.NoError(t, err, scope) + } + } +} + +func TestFetchRecommendationsForRegion_PassesSPAccountScope(t *testing.T) { + captureAppLog(t) + client := &MockRecommendationsClient{} + var got *common.RecommendationParams + client.On("GetRecommendations", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { got = args.Get(1).(*common.RecommendationParams) }). + Return([]common.Recommendation{}, nil) + + fetchRecommendationsForRegion(context.Background(), client, common.ServiceSavingsPlansCompute, spAPIRegion, + Config{TermYears: 1, SPAccountScope: "payer"}) + + require.NotNil(t, got) + assert.Equal(t, "payer", got.SPAccountScope) +} diff --git a/cmd/validators.go b/cmd/validators.go index f49d44f35..24ba719c3 100644 --- a/cmd/validators.go +++ b/cmd/validators.go @@ -47,7 +47,16 @@ func validateFlags(cmd *cobra.Command, args []string) error { return err } - return nil + return validateSPAccountScope() +} + +// validateSPAccountScope validates --sp-account-scope. Empty means linked. +func validateSPAccountScope() error { + switch strings.ToLower(toolCfg.SPAccountScope) { + case "", "linked", "payer": + return nil + } + return fmt.Errorf("invalid sp-account-scope %q: must be linked or payer", toolCfg.SPAccountScope) } func validateServices(cmd *cobra.Command) error { diff --git a/docs/cli/README.md b/docs/cli/README.md index fb92213d1..17f69e539 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -73,6 +73,7 @@ All flags belong to the root command unless noted otherwise. | `--exclude-accounts` | (none) | Exclude recommendations for these account names. | | `--include-sp-types` | (all) | Only include these Savings Plan types: `Compute`, `EC2Instance`, `SageMaker`, `Database`. | | `--exclude-sp-types` | (none) | Exclude these Savings Plan types. | +| `--sp-account-scope` | `linked` | Cost Explorer account scope for Savings Plan recommendations: `linked` or `payer`. A management account may only get recommendations at `payer` scope. | | `--include-extended-support` | `false` | By default, instances running engine versions in AWS Extended Support are excluded because Extended Support surcharges can erase RI savings. Pass this flag to include them. | ### Target-coverage helpers diff --git a/go.mod b/go.mod index 8389b9b41..fc9b593be 100644 --- a/go.mod +++ b/go.mod @@ -82,8 +82,8 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 - github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012195142-218912bb8cb9 - github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012195142-218912bb8cb9 + github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013183209-f794d75afb69 + github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013183209-f794d75afb69 github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb github.com/aws/aws-sdk-go-v2/service/organizations v1.45.3 diff --git a/go.sum b/go.sum index f857c05f1..a695a6583 100644 --- a/go.sum +++ b/go.sum @@ -78,10 +78,10 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012195142-218912bb8cb9 h1:b1AVTF1yVwPgWo9/6SlNVO460EMqWTsLZ2BSKcrwfjA= -github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261012195142-218912bb8cb9/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012195142-218912bb8cb9 h1:We82Ai0sL37/PTC14qIJub8B+d06qWHFnIco5XhVX2U= -github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012195142-218912bb8cb9/go.mod h1:fiu6WQmUGRdViiOf45QV235Cowq8j0yn8ahGWCGEFn8= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013183209-f794d75afb69 h1:MHTNQEtRh+K6OcCJlETArAB8G/RWRC4eGqcWlhMZplk= +github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261013183209-f794d75afb69/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013183209-f794d75afb69 h1:Ra+eyZPRa/R1YcTPVnPR3o/ywDs8TO+UuPJ8W+tck7A= +github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261013183209-f794d75afb69/go.mod h1:no7i/7i+W37+qhEOVU8F/0dP5FhO3wtzvKIN0+tvL3A= github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b h1:yurugmfdZjf+dqjfqfDyfoBR3IAiFUpMKQ8URnZnJzQ= github.com/LeanerCloud/cloud-commitments-go/providers/azure v0.0.0-20261007133317-58c25f04c49b/go.mod h1:iIvPuWLWd+SXqLHAQNdodYMUAAcq7PnwfScXWgARt5A= github.com/LeanerCloud/cloud-commitments-go/providers/gcp v0.0.0-20261006205158-7ff8c1aee1bb h1:AJ4nKDtttjiTDemo9PDp2esoc35ymVaiCSr0zIewwpY=