{{ message }}
Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
417 lines (382 loc) · 19 KB
/
Copy pathmain.go
File metadata and controls
417 lines (382 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"regexp"
"sort"
"strings"
"time"
"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/LeanerCloud/cloud-commitments-go/pkg/provider"
_ "github.com/LeanerCloud/cloud-commitments-go/providers/aws"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/recommendations"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/ec2"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/elasticache"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/memorydb"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/opensearch"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/rds"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/redshift"
"github.com/LeanerCloud/cloud-commitments-go/providers/aws/services/savingsplans"
_ "github.com/LeanerCloud/cloud-commitments-go/providers/azure"
_ "github.com/LeanerCloud/cloud-commitments-go/providers/gcp"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
const (
// MaxReasonableInstances is the maximum number of instances that can be processed
// This is a safety limit to prevent accidental large purchases.
MaxReasonableInstances = 10000
)
// Config holds all configuration for the RI helper tool.
type Config struct {
// CallerAccountID is the account the credentials belong to (STS
// GetCallerIdentity), set at run start. Purchases are refused for
// recommendations owned by any other account.
CallerAccountID string
AuditLog string
CSVInput string
IdempotencyWindow string
ValidationProfile string
Profile string
PaymentOption string
CSVOutput string
SPAccountScope string
RIAccountScope string
// RecLookbackPeriod controls the LookbackPeriodInDays passed to
// GetReservationPurchaseRecommendation. Valid values: "7d", "30d", "60d"
// (recommendations.DefaultRecLookbackPeriod is the shared default).
// A longer window smooths seasonal spikes; a shorter window weights
// recent demand more heavily.
RecLookbackPeriod string
Regions []string
Services []string
ExcludeAccounts []string
IncludeRegions []string
ExcludeRegions []string
IncludeInstanceTypes []string
ExcludeInstanceTypes []string
IncludeEngines []string
IncludeAccounts []string
ExcludeEngines []string
ExcludeSPTypes []string
IncludeSPTypes []string
TargetCoverage float64
Coverage float64
MinPoolSize float64
MinSavingsPct float64
MaxBreakEvenMonths int
RebuyWindowDays int
TermYears int
CoverageLookbackDays int
MinCount int
IdempotencyWindowHours int
OverrideCount int32
MaxInstances int32
IncludeExtendedSupport bool
AllServices bool
ActualPurchase bool
// CoverageFromDefault is true when --coverage was not passed on the
// command line, so Coverage holds the flag's default. runTool sets it
// from cobra's Changed(); --input-csv runs use it to buy each row's full
// Count when no coverage was asked for (determineCSVCoverage). A Config
// built in code leaves it false, so its Coverage is always honored.
CoverageFromDefault bool
}
func main() {
if err := rootCmd.Execute(); err != nil {
log.Fatalf("Error executing command: %v", err)
}
}
var rootCmd = &cobra.Command{
Use: "ri-helper",
Short: "AWS Reserved Instance purchase tool based on Cost Explorer recommendations",
Long: `A tool that fetches Reserved Instance recommendations from AWS Cost Explorer
for multiple services (RDS, ElastiCache, EC2, OpenSearch, Redshift, MemoryDB) and
purchases them based on specified coverage percentage. Supports multiple regions.`,
PreRunE: validateFlags,
Run: runTool,
}
func init() {
// Note: We still bind to package-level variables here for cobra's flag system
// These will be copied into a ToolConfig in runTool
rootCmd.Flags().StringSliceVarP(&toolCfg.Regions, "regions", "r", []string{}, "AWS regions (comma-separated or multiple flags). If empty, auto-discovers regions from recommendations")
rootCmd.Flags().StringSliceVarP(&toolCfg.Services, "services", "s", []string{"rds"}, "Services to process (rds, elasticache, ec2, opensearch, redshift, memorydb, savingsplans)")
rootCmd.Flags().BoolVar(&toolCfg.AllServices, "all-services", false, "Process all supported services")
rootCmd.Flags().Float64VarP(&toolCfg.Coverage, "coverage", "c", 80.0, "Percentage of recommendations to purchase (0-100)")
rootCmd.Flags().Float64VarP(&toolCfg.TargetCoverage, "target-coverage", "u", 0,
"Target % (0-100) of historical avg-hourly usage to cover with commitments. "+
"When >0, sizes each rec to floor(avg * target/100) so projected coverage "+
"approximates target and projected utilization stays near 100%, leaving "+
"(100-target)% on-demand headroom. Overrides --coverage. Default 0 = disabled.")
rootCmd.Flags().BoolVar(&toolCfg.ActualPurchase, "purchase", false, "Actually purchase RIs instead of just printing the data")
rootCmd.Flags().StringVarP(&toolCfg.CSVOutput, "output", "o", "", "Output CSV file path (if not specified, auto-generates filename)")
rootCmd.Flags().StringVarP(&toolCfg.CSVInput, "input-csv", "i", "", "Input CSV file with recommendations to purchase")
rootCmd.Flags().StringVarP(&toolCfg.PaymentOption, "payment", "p", "no-upfront", "Payment option (all-upfront, partial-upfront, no-upfront)")
rootCmd.Flags().IntVarP(&toolCfg.TermYears, "term", "t", 3, "Term in years (1 or 3)")
rootCmd.Flags().StringVar(&toolCfg.Profile, "profile", "", "AWS profile to use (defaults to AWS_PROFILE env var or default profile)")
// Filter flags
rootCmd.Flags().StringSliceVar(&toolCfg.IncludeRegions, "include-regions", []string{}, "Only include recommendations for these regions (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeRegions, "exclude-regions", []string{}, "Exclude recommendations for these regions (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.IncludeInstanceTypes, "include-instance-types", []string{}, "Only include these instance types (comma-separated, e.g., 'db.t3.micro,cache.t3.small')")
rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeInstanceTypes, "exclude-instance-types", []string{}, "Exclude these instance types (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.IncludeEngines, "include-engines", []string{}, "Only include these engines (comma-separated, e.g., 'redis,mysql,postgresql')")
rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeEngines, "exclude-engines", []string{}, "Exclude these engines (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.IncludeAccounts, "include-accounts", []string{}, "Only include recommendations for these accounts, matched exactly (case-insensitive) on account name or ID (comma-separated)")
rootCmd.Flags().StringSliceVar(&toolCfg.ExcludeAccounts, "exclude-accounts", []string{}, "Exclude recommendations for these accounts, matched exactly (case-insensitive) on account name or ID; a value that only partly matches account names stops the run (comma-separated)")
rootCmd.Flags().Int32Var(&toolCfg.MaxInstances, "max-instances", 0, "Maximum total number of instances to purchase (0 = no limit); does not apply to Savings Plans")
rootCmd.Flags().Int32Var(&toolCfg.OverrideCount, "override-count", 0, "Override recommendation count with fixed number for all selected RIs (0 = use recommendation or coverage)")
rootCmd.Flags().StringVar(&toolCfg.ValidationProfile, "validation-profile", "", "AWS profile to use for validating running instances (if different from main profile)")
rootCmd.Flags().BoolVar(&toolCfg.IncludeExtendedSupport, "include-extended-support", false, "Include instances running on extended support engine versions (by default they are excluded)")
// Savings Plans specific filters
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.RIAccountScope, "ri-account-scope", "linked", "Cost Explorer account scope for Reserved Instance recommendations: linked or payer (payer applies to a management account)")
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")
rootCmd.Flags().Float64Var(&toolCfg.MinSavingsPct, "min-savings-pct", 0, "Minimum savings percentage to include a recommendation (0 = no filter)")
rootCmd.Flags().IntVar(&toolCfg.MaxBreakEvenMonths, "max-break-even-months", 0, "Maximum break-even period in months (0 = no filter)")
rootCmd.Flags().IntVar(&toolCfg.MinCount, "min-count", 0, "Minimum instance count to include a recommendation (0 = no filter); does not apply to Savings Plans")
rootCmd.Flags().IntVar(&toolCfg.CoverageLookbackDays, "coverage-lookback-days", 30,
"Number of calendar days of historical demand fed to GetReservationCoverage "+
"when computing the existing-RI coverage map for --target-coverage sizing. "+
"Match this to your AWS console coverage report window to reconcile "+
"CUDly's ExistingCoverage column against the console export. Default 30.")
rootCmd.Flags().IntVar(&toolCfg.RebuyWindowDays, "rebuy-window-days", 0,
"When >0, treat existing RIs expiring within this many days as already "+
"uncovered, so --target-coverage sizes recommendations to replace them "+
"before they expire. Default 0 = trust existing coverage fully.")
rootCmd.Flags().Float64Var(&toolCfg.MinPoolSize, "min-pool-size", 0,
"When >0, drop RI recommendations for pools with AverageInstancesUsedPerHour "+
"below this threshold. Useful with --target-coverage to skip tiny pools "+
"that integer arithmetic forces above target (e.g. avg=1 cannot hit 80%%). "+
"Default 0 = no filter.")
rootCmd.Flags().StringVar(&toolCfg.RecLookbackPeriod, "rec-lookback-period", recommendations.DefaultRecLookbackPeriod,
"Historical window for GetReservationPurchaseRecommendation. "+
"Valid values: 7d, 30d, 60d. A longer window smooths seasonal spikes; "+
"a shorter window weights recent demand more heavily. Default 7d.")
}
// Package-level Config that cobra flags bind to.
var toolCfg = Config{}
// validateFlags is now defined in validators.go
// Legacy Savings Plans aliases fan out to every plan type for existing CLI scripts.
// Deduplication prevents overlapping aliases from processing a service twice.
func parseServices(serviceNames []string) ([]common.ServiceType, error) {
var result []common.ServiceType
seen := make(map[common.ServiceType]struct{})
add := func(service common.ServiceType) {
if _, ok := seen[service]; ok {
return
}
seen[service] = struct{}{}
result = append(result, service)
}
allSPSlugs := []common.ServiceType{
common.ServiceSavingsPlansCompute,
common.ServiceSavingsPlansEC2Instance,
common.ServiceSavingsPlansSageMaker,
common.ServiceSavingsPlansDatabase,
}
serviceMap := map[string]common.ServiceType{
"rds": common.ServiceRDS,
"elasticache": common.ServiceElastiCache,
"ec2": common.ServiceEC2,
"opensearch": common.ServiceOpenSearch,
"elasticsearch": common.ServiceOpenSearch, // Legacy alias maps to OpenSearch
"redshift": common.ServiceRedshift,
"memorydb": common.ServiceMemoryDB,
"savingsplans-compute": common.ServiceSavingsPlansCompute,
"savingsplans-ec2instance": common.ServiceSavingsPlansEC2Instance,
"savingsplans-sagemaker": common.ServiceSavingsPlansSageMaker,
"savingsplans-database": common.ServiceSavingsPlansDatabase,
"savings-plans-compute": common.ServiceSavingsPlansCompute,
"savings-plans-ec2instance": common.ServiceSavingsPlansEC2Instance,
"savings-plans-sagemaker": common.ServiceSavingsPlansSageMaker,
"savings-plans-database": common.ServiceSavingsPlansDatabase,
}
for _, name := range serviceNames {
key := strings.ToLower(name)
if key == "savingsplans" || key == "savings-plans" || key == "sp" {
for _, service := range allSPSlugs {
add(service)
}
continue
}
if service, ok := serviceMap[key]; ok {
add(service)
} else {
validNames := make([]string, 0, len(serviceMap)+3)
validNames = append(validNames, "savingsplans", "savings-plans", "sp")
for supported := range serviceMap {
validNames = append(validNames, supported)
}
sort.Strings(validNames)
return nil, fmt.Errorf("unknown service %q; valid services: %s", name, strings.Join(validNames, ", "))
}
}
return result, nil
}
// getAllServices returns all supported services.
func getAllServices() []common.ServiceType {
return []common.ServiceType{
common.ServiceRDS,
common.ServiceElastiCache,
common.ServiceEC2,
common.ServiceOpenSearch,
common.ServiceRedshift,
common.ServiceMemoryDB,
common.ServiceSavingsPlansCompute,
common.ServiceSavingsPlansEC2Instance,
common.ServiceSavingsPlansSageMaker,
common.ServiceSavingsPlansDatabase,
}
}
// createServiceClient creates the appropriate service client for a service.
func createServiceClient(service common.ServiceType, cfg aws.Config) provider.ServiceClient {
switch service {
case common.ServiceRDS:
return rds.NewClient(cfg)
case common.ServiceElastiCache:
return elasticache.NewClient(cfg)
case common.ServiceEC2:
return ec2.NewClient(cfg)
case common.ServiceOpenSearch:
return opensearch.NewClient(cfg)
case common.ServiceRedshift:
return redshift.NewClient(cfg)
case common.ServiceMemoryDB:
return memorydb.NewClient(cfg)
case common.ServiceSavingsPlansCompute,
common.ServiceSavingsPlansEC2Instance,
common.ServiceSavingsPlansSageMaker,
common.ServiceSavingsPlansDatabase:
pt, ok := savingsplans.PlanTypeForServiceType(service)
if !ok {
return nil
}
return savingsplans.NewClient(cfg, pt)
default:
return nil
}
}
// effectiveSizingPct returns the sizing % actually applied to recommendations:
// cfg.TargetCoverage when set (>0), else cfg.Coverage. Use this when
// emitting human-facing labels (purchase IDs, audit-log fields) so the label
// reflects the value that drove the sizing, not the unused default.
func effectiveSizingPct(cfg Config) float64 {
if cfg.TargetCoverage > 0 {
return cfg.TargetCoverage
}
return cfg.Coverage
}
// extractEngineLabel returns the engine or platform string from the
// polymorphic Details field. DatabaseDetails/CacheDetails are always
// pointers (every producer constructs them that way; see
// pkg/common/service_details_codec.go's package doc for the pointer
// invariant). ComputeDetails is still accepted as a value because the
// Azure compute client and the GCP compute-engine client both construct
// it that way.
func extractEngineLabel(details interface{}) string {
switch d := details.(type) {
case *common.DatabaseDetails:
if d != nil {
return d.Engine
}
case *common.CacheDetails:
if d != nil {
return d.Engine
}
case common.ComputeDetails:
return d.Platform
case *common.ComputeDetails:
if d != nil {
return d.Platform
}
}
return ""
}
// generatePurchaseID creates a descriptive purchase ID with UUID for uniqueness.
// sizingPct is the percentage that actually drove the sizing decision (see
// effectiveSizingPct); it appears in the ID as e.g. "80pct" purely for human
// readability and audit traceability.
func generatePurchaseID(rec common.Recommendation, region string, _ int, isDryRun bool, sizingPct float64) string {
// Generate a short UUID suffix (first 8 characters) for uniqueness
uuidSuffix := uuid.New().String()[:8]
timestamp := time.Now().Format("20060102-150405")
prefix := "ri"
if isDryRun {
prefix = "dryrun"
}
service := strings.ToLower(string(rec.Service))
instanceType := strings.ReplaceAll(rec.ResourceType, ".", "-")
raw := extractEngineLabel(rec.Details)
engine := strings.ToLower(raw)
engine = strings.ReplaceAll(engine, " ", "-")
engine = strings.ReplaceAll(engine, "_", "-")
engine = strings.ReplaceAll(engine, "/", "-")
// Add account name if available
accountName := sanitizeAccountName(rec.AccountName)
coveragePct := fmt.Sprintf("%.0fpct", sizingPct)
if accountName != "" {
if engine != "" {
return fmt.Sprintf("%s-%s-%s-%s-%s-%s-%dx-%s-%s-%s",
prefix, accountName, service, engine, region, instanceType, rec.Count, coveragePct, timestamp, uuidSuffix)
}
return fmt.Sprintf("%s-%s-%s-%s-%s-%dx-%s-%s-%s",
prefix, accountName, service, region, instanceType, rec.Count, coveragePct, timestamp, uuidSuffix)
}
// Fallback without account name
if engine != "" {
return fmt.Sprintf("%s-%s-%s-%s-%s-%dx-%s-%s-%s",
prefix, service, engine, region, instanceType, rec.Count, coveragePct, timestamp, uuidSuffix)
}
return fmt.Sprintf("%s-%s-%s-%s-%dx-%s-%s-%s",
prefix, service, region, instanceType, rec.Count, coveragePct, timestamp, uuidSuffix)
}
// sanitizeAccountName converts account name to a filesystem/ID-safe format.
func sanitizeAccountName(accountName string) string {
if accountName == "" {
return ""
}
// Convert to lowercase
clean := strings.ToLower(accountName)
// Replace spaces and special chars with hyphens
clean = strings.ReplaceAll(clean, " ", "-")
clean = strings.ReplaceAll(clean, "_", "-")
clean = strings.ReplaceAll(clean, ".", "-")
// Remove any characters that aren't alphanumeric or hyphens
var b strings.Builder
for _, r := range clean {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
b.WriteRune(r)
}
}
result := b.String()
// Remove leading/trailing hyphens and collapse multiple hyphens
result = strings.Trim(result, "-")
result = regexp.MustCompile("-{2,}").ReplaceAllString(result, "-")
return result
}
func runTool(cmd *cobra.Command, args []string) {
ctx, stop := invocationContext()
defer stop()
cfg := toolCfg
cfg.CoverageFromDefault = cmd == nil || !cmd.Flags().Changed("coverage")
// Always use the multi-service implementation
runToolMultiService(ctx, cfg)
}
// invocationContext returns the context for one CLI invocation, canceled by
// SIGINT. stop is for the caller to defer, never to call on the first signal:
// stopping NotifyContext restores the default handler, so a second Ctrl-C
// would kill the process mid-purchase. Keeping SIGINT registered until the
// run returns absorbs further Ctrl-Cs; the wait is bounded by the per-call SDK
// timeouts and the pre-purchase phases exit at once (see
// registerShutdownSignalHandler for the operator notice).
func invocationContext() (ctx context.Context, stop context.CancelFunc) {
return signal.NotifyContext(context.Background(), os.Interrupt)
}
You can’t perform that action at this time.
