diff --git a/cmd/archera_comparison.go b/cmd/archera_comparison.go
index a70d35627..047672887 100644
--- a/cmd/archera_comparison.go
+++ b/cmd/archera_comparison.go
@@ -81,7 +81,7 @@ func runArcheraComparison(cmd *cobra.Command, _ []string) error {
// Config and client stay local to this call; never store them in a
// package variable or struct.
- client, err := insurance.NewClient(insurance.Config{APIKey: key, OrgID: orgID}, archeraHTTPClient)
+ client, err := insurance.NewClient(insurance.Config{APIKey: insurance.NewSecret(key), OrgID: orgID}, archeraHTTPClient)
if err != nil {
return err
}
diff --git a/cmd/archera_comparison_key_test.go b/cmd/archera_comparison_key_test.go
index 586af8194..6aee40537 100644
--- a/cmd/archera_comparison_key_test.go
+++ b/cmd/archera_comparison_key_test.go
@@ -145,6 +145,11 @@ func TestArcheraLongKeyVisibleDisguisesAreWithheld(t *testing.T) {
}
for name, echo := range echoes {
forArcheraErrorPaths(t, echo, []int{0}, func(path, got string) {
+ if path == "decode" {
+ // pkg/insurance no longer echoes vendor values in decode errors.
+ assert.Empty(t, archeraReadableKeyFragment(got), "%s %s: %q", path, name, got)
+ return
+ }
assert.Contains(t, got, archeraWithheld, "%s %s: %q", path, name, got)
})
}
diff --git a/cmd/archera_comparison_test.go b/cmd/archera_comparison_test.go
index d8666eebf..01a141627 100644
--- a/cmd/archera_comparison_test.go
+++ b/cmd/archera_comparison_test.go
@@ -497,7 +497,8 @@ func TestArcheraErrorsMaskObfuscatedKey(t *testing.T) {
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut+err.Error(), k)
- assert.Contains(t, err.Error(), "[redacted]")
+ // pkg/insurance no longer echoes the rejected value at all.
+ assert.Contains(t, err.Error(), "not shown")
})
t.Run("plain key as enum value", func(t *testing.T) {
body := strings.Replace(archeraComparisonFixture(), `"payment_option":"no_upfront"`, `"payment_option":"`+k+`"`, 1)
@@ -567,7 +568,11 @@ func TestArcheraErrorsWithholdKeyHiddenBehindSurvivingCharacters(t *testing.T) {
out, errOut, err := runArchera(t, fullEnv())
require.Error(t, err)
assert.NotContains(t, out+errOut, k)
- checkMsg(t, err.Error())
+ // pkg/insurance no longer echoes the rejected value, so there is
+ // nothing to withhold; the key must still not appear.
+ assert.NotContains(t, err.Error(), k)
+ assertArcheraNoControlChars(t, err.Error())
+ assert.LessOrEqual(t, len(err.Error()), archeraMaxErrorSize)
})
for kind, wrapped := range map[string]error{
"transport": errors.New(msg),
diff --git a/cmd/multi_service.go b/cmd/multi_service.go
index 33b46cfc7..2085869b6 100644
--- a/cmd/multi_service.go
+++ b/cmd/multi_service.go
@@ -51,10 +51,7 @@ func fetchExistingCoverage(ctx context.Context, awsCfg aws.Config, recClient pro
// not subject to the fail-closed behavior below.
return nil, nil
}
- lookbackDays := cfg.CoverageLookbackDays
- if lookbackDays <= 0 {
- lookbackDays = 30
- }
+ lookbackDays := coverageLookbackDays(cfg)
regions := cfg.Regions
if len(regions) == 0 {
allRegions, err := getAllAWSRegions(ctx, awsCfg)
@@ -1059,3 +1056,12 @@ func exitOnExclusionError(err error) {
log.Fatalf("Cannot apply extended-support exclusion: %v", err)
}
}
+
+// coverageLookbackDays is the Cost Explorer coverage lookback: the one value
+// given to GetRICoverageMap and to the RDS family sizing, which must agree.
+func coverageLookbackDays(cfg Config) int {
+ if cfg.CoverageLookbackDays <= 0 {
+ return 30
+ }
+ return cfg.CoverageLookbackDays
+}
diff --git a/cmd/multi_service_helpers.go b/cmd/multi_service_helpers.go
index c420bfa7d..19b69e469 100644
--- a/cmd/multi_service_helpers.go
+++ b/cmd/multi_service_helpers.go
@@ -469,6 +469,8 @@ func processRegionRecommendations(
// fetch expiring commitments — expiry-aware sizing only runs via the
// main pipeline in fetchAndFilterRegionRecs, which has access to the
// regional service client at the right moment. Drop tracking skipped (nil).
+ // nil commitments: no expiry deduction, so family sizing is identical to
+ // ApplyFamilyNUSizingRDS (documented on applyCoverageAndOverrides).
filteredRecs := applyCoverageAndOverrides(recs, cfg, coverageMap, nil, nil)
result.recommendations = filteredRecs
@@ -616,6 +618,13 @@ func applyRegionFilters(
// ExistingCoveragePct further by the share of pool demand attributable to
// RIs expiring within the window, so --target-coverage recommends
// replacements before the cliff. Doesn't run if either input is empty.
+// RDS recs sized by the family-NU pass (target-coverage mode) ignore that
+// per-rec adjustment and instead get the same commitments and window through
+// ApplyFamilyNUSizingRDSWithExpiryOptions, which deducts expiring normalized units
+// from the family's coverage and credits replacement RIs bought inside the
+// coverage lookback (so a re-run does not buy them again); this deliberately
+// raises RDS counts and costs when RIs are about to expire (cli#2132). Callers passing nil commitments
+// get the plain ApplyFamilyNUSizingRDS behavior.
//
// drops accumulates per-reason drop counts for the end-of-run summary.
// Pass nil to skip tracking.
@@ -640,7 +649,12 @@ func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverag
rest := recs
if cfg.TargetCoverage > 0 {
var familyDrops recommendations.FamilyDropCounts
- sizedRDS, rest, familyDrops = recommendations.ApplyFamilyNUSizingRDS(recs, coverageMap, cfg.TargetCoverage)
+ var expiry recommendations.FamilyExpiryReport
+ lookbackDays := coverageLookbackDays(cfg)
+ sizedRDS, rest, familyDrops, expiry = recommendations.ApplyFamilyNUSizingRDSWithExpiryOptions(
+ recs, coverageMap, cfg.TargetCoverage, expiringCommitments,
+ recommendations.FamilyExpiryOptions{WindowDays: cfg.RebuyWindowDays, CoverageLookbackDays: lookbackDays})
+ logFamilyExpiry(expiry, cfg.RebuyWindowDays, lookbackDays)
drops.Add(common.DropFamilyAlreadyAtTarget, familyDrops.AlreadyAtTarget)
drops.Add(common.DropFamilyNoNUSignal, familyDrops.NoNUSignal)
drops.Add(common.DropFamilySizedToZero, familyDrops.SizedToZero)
@@ -662,6 +676,21 @@ func applyCoverageAndOverrides(recs []common.Recommendation, cfg Config, coverag
return filteredRecs
}
+// logFamilyExpiry reports how rebuy-window expiry was folded into the RDS
+// family-NU sizing. Unusable RIs (unparsable instance class, or an engine
+// spelled differently from the coverage report) were NOT deducted, so they are
+// warned about rather than read as "no expiry".
+func logFamilyExpiry(expiry recommendations.FamilyExpiryReport, windowDays, lookbackDays int) {
+ if expiry.ExpiringNU > 0 {
+ // The exact deduction is capped by the covered and active NU per family
+ // (go#342); report the inputs rather than claim a net figure.
+ AppLogger.Printf(" ⏰ RDS family sizing: %.0f normalized units of RIs expiring within %d days considered against family coverage; %.0f units of replacement RIs started in the last %d days credited back\n", expiry.ExpiringNU, windowDays, expiry.ReplacementNU, lookbackDays)
+ }
+ if expiry.Unusable > 0 {
+ AppLogger.Printf(" ⚠️ %d RDS reserved instances expiring within %d days could not be applied to family sizing (unparsable instance class or no matching coverage family); their expiry was NOT deducted\n", expiry.Unusable, windowDays)
+ }
+}
+
// checkDuplicates adjusts recommendations against already-owned RIs so the run
// does not double-purchase existing capacity.
//
diff --git a/cmd/rds_family_expiry_command_test.go b/cmd/rds_family_expiry_command_test.go
new file mode 100644
index 000000000..77ef1d4cc
--- /dev/null
+++ b/cmd/rds_family_expiry_command_test.go
@@ -0,0 +1,429 @@
+package main
+
+// Command-level regression tests for cli#2132: with --target-coverage and
+// --rebuy-window-days, RDS family-NU sizing must deduct RIs that expire inside
+// the window from the family's existing coverage. The harness drives the real
+// root command against a recorded-HTTP proxy (Cost Explorer, RDS and
+// Organizations responses are synthetic fixtures), dry-run only: no purchase
+// operation is permitted.
+//
+// Every scenario below that is not labeled "control" fails on the commit
+// before the fix (the family was sized against the un-adjusted coverage).
+
+import (
+ "bytes"
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+const rdsExpiryService = "rds-expiry"
+
+// rdsExpiryUsage is one Cost Explorer coverage row: average running instances
+// per hour of one class and the share of those hours covered by RIs.
+type rdsExpiryUsage struct {
+ engine, class string
+ avg, pct float64
+}
+
+// rdsExpiryRI is one DescribeReservedDBInstances entry.
+type rdsExpiryRI struct {
+ class, engine string
+ multiAZ bool
+ count int
+ endDays int
+ // startedDaysAgo overrides the derived start (one year before endDays).
+ startedDaysAgo int
+}
+
+// rdsExpiryRec is one Cost Explorer purchase recommendation row (one linked
+// account, one class). The fixture prices every recommended instance at 100
+// upfront, 100 monthly savings and 10 recurring, so costs scale with count.
+type rdsExpiryRec struct {
+ account int
+ class string
+ count int
+}
+
+type rdsExpiryScenario struct {
+ usage []rdsExpiryUsage
+ ris []rdsExpiryRI
+ recs []rdsExpiryRec
+ // want is the expected Count per rec after sizing (0 = the rec is absent
+ // from the CSV). existing is the expected family ExistingCoverage column
+ // for the surviving rows. unusable is the expected number of expiring RIs
+ // reported as not applied to family sizing.
+ want []int
+ existing float64
+ unusable int
+ // logLine, when set, must appear in the run log.
+ logLine string
+ // parent documents the count the commit before the fix produced.
+ parent []int
+}
+
+const rdsExpiryTarget = 80
+
+var rdsExpiryAccountIDs = func() []string {
+ ids := make([]string, 4)
+ for i := range ids {
+ ids[i] = strings.Repeat(strconv.Itoa(i+1), 12)
+ }
+ return ids
+}()
+
+func rdsExpiryCase(name string) rdsExpiryScenario {
+ mysql := func(class string, avg, pct float64) rdsExpiryUsage {
+ return rdsExpiryUsage{engine: "MySQL", class: class, avg: avg, pct: pct}
+ }
+ ri := func(class string, count, endDays int) rdsExpiryRI {
+ return rdsExpiryRI{class: class, engine: "mysql", count: count, endDays: endDays}
+ }
+ one := func(class string, count int) []rdsExpiryRec { return []rdsExpiryRec{{0, class, count}} }
+ switch name {
+ case "already-at-target":
+ // 80% covered by 16 large RIs, 3 of them expiring: the family looks at
+ // target, so the parent drops it. After expiry it is 65% covered and
+ // the 15% gap (12 NU) buys exactly the 3 expiring RIs back.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 80)},
+ ris: []rdsExpiryRI{ri("db.r6g.large", 13, 200), ri("db.r6g.large", 3, 15)},
+ recs: one("db.r6g.large", 8), want: []int{3}, existing: 65, parent: []int{0},
+ }
+ case "below-target":
+ // 40% covered by 8 large RIs, 2 expiring: post-expiry 30%, so the gap
+ // is 50% (40 NU) = 10 large instead of the parent's 8.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 40)},
+ ris: []rdsExpiryRI{ri("db.r6g.large", 6, 200), ri("db.r6g.large", 2, 15)},
+ recs: one("db.r6g.large", 8), want: []int{10}, existing: 30, parent: []int{8},
+ }
+ case "large-ri-xlarge-usage":
+ // Cost Explorer reports coverage under the running size (xlarge) while
+ // the expiring RIs are large: 4 large = 16 NU expire, so the family
+ // drops from 80% to 60% and buys 2 xlarge (16 NU).
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.xlarge", 10, 80)},
+ ris: []rdsExpiryRI{ri("db.r6g.large", 12, 200), ri("db.r6g.large", 4, 15)},
+ recs: one("db.r6g.xlarge", 4), want: []int{2}, existing: 60, parent: []int{0},
+ }
+ case "multi-size-linked-accounts":
+ // Two sizes and three linked-account rows in one family: the 16 expiring
+ // NU are deducted once (family 40% -> 20%), so the 60% gap (48 NU) splits
+ // 2 + 4 + 2. A double deduction would buy 2 + 5 + 2; no deduction, 1 + 2 + 1.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.xlarge", 5, 40), mysql("db.r6g.large", 10, 40)},
+ ris: []rdsExpiryRI{ri("db.r6g.large", 4, 200), ri("db.r6g.large", 4, 15)},
+ recs: []rdsExpiryRec{
+ {0, "db.r6g.xlarge", 4}, {1, "db.r6g.large", 8}, {2, "db.r6g.xlarge", 4},
+ },
+ want: []int{2, 4, 2}, existing: 20, parent: []int{1, 2, 1},
+ }
+ case "partial-inventory":
+ // Cost Explorer covers 64 NU org-wide but the single-account inventory
+ // lists only the 4 expiring large RIs (16 NU); the other accounts' RIs
+ // are missing. The deduction is capped at the 16 expiring NU (80% ->
+ // 60%, buy 4 large), never the whole supplied-vs-covered difference.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 80)},
+ ris: []rdsExpiryRI{ri("db.r6g.large", 4, 15)},
+ recs: one("db.r6g.large", 8), want: []int{4}, existing: 60, parent: []int{0},
+ }
+ case "unusable-expiring-warned":
+ // Expiring RIs the family pass cannot use (multi-AZ with no coverage
+ // family, and an unparsable class) must be reported, not read as "no
+ // expiry". Counts are unchanged from the parent; only the warning is new.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 40)},
+ ris: []rdsExpiryRI{
+ ri("db.r6g.large", 8, 200),
+ {class: "db.r6g.large", engine: "mysql", multiAZ: true, count: 2, endDays: 15},
+ ri("db.t3.weird", 1, 15),
+ },
+ recs: one("db.r6g.large", 8), want: []int{8}, existing: 40, unusable: 2, parent: []int{8},
+ }
+ case "control-outside-window":
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 40)},
+ ris: []rdsExpiryRI{ri("db.r6g.large", 8, 180)},
+ recs: one("db.r6g.large", 8), want: []int{8}, existing: 40, parent: []int{8},
+ }
+ case "control-engine-isolation":
+ // The expiring RIs belong to a PostgreSQL family; the MySQL family must
+ // not be touched.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 40), {engine: "PostgreSQL", class: "db.r6g.large", avg: 20, pct: 80}},
+ ris: []rdsExpiryRI{{class: "db.r6g.large", engine: "postgres", count: 16, endDays: 15}},
+ recs: one("db.r6g.large", 8), want: []int{8}, existing: 40, parent: []int{8},
+ }
+ case "rerun-replacement-bought":
+ // Re-run after the CLI already bought the replacements (cli#2175 gate F1).
+ // Lagged coverage is 82.5% (66 NU): 12 large owned by another account (not
+ // in the inventory), the 4 expiring, and part of the 4 replacements bought
+ // 2 days ago (outside the 24h idempotency window). True post-expiry
+ // coverage is 12 + 4 replacements = 16 large = 64 NU = 80%, so nothing is
+ // bought. Before the lookback-aware call the lagged coverage lost the 16
+ // expiring NU and 3 large were bought again.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 82.5)},
+ ris: []rdsExpiryRI{
+ ri("db.r6g.large", 4, 15),
+ {class: "db.r6g.large", engine: "mysql", count: 4, endDays: 363, startedDaysAgo: 2},
+ },
+ recs: one("db.r6g.large", 8), want: []int{0}, parent: []int{3},
+ logLine: "16 normalized units of RIs expiring within 30 days considered against family coverage; 16 units of replacement RIs started in the last 30 days credited back",
+ }
+ case "control-replacement-outside-lookback":
+ // Same inventory but the replacements started 45 days ago, outside the
+ // 30-day coverage lookback, so they are already inside the coverage
+ // figure and must not be credited again: 66 - 16 = 50 NU (62.5%), the
+ // 17.5% gap (14 NU) buys 3 large.
+ return rdsExpiryScenario{
+ usage: []rdsExpiryUsage{mysql("db.r6g.large", 20, 82.5)},
+ ris: []rdsExpiryRI{
+ ri("db.r6g.large", 4, 15),
+ {class: "db.r6g.large", engine: "mysql", count: 4, endDays: 320, startedDaysAgo: 45},
+ },
+ recs: one("db.r6g.large", 8), want: []int{3}, existing: 62.5, parent: []int{3},
+ }
+ }
+ panic("unknown rds expiry scenario: " + name)
+}
+
+func TestRDSFamilyExpiryCommand(t *testing.T) {
+ for _, name := range []string{
+ "already-at-target", "below-target", "large-ri-xlarge-usage", "multi-size-linked-accounts",
+ "partial-inventory", "unusable-expiring-warned", "control-outside-window", "control-engine-isolation",
+ "rerun-replacement-bought", "control-replacement-outside-lookback",
+ } {
+ t.Run(name, func(t *testing.T) { runCompletenessScenario(t, rdsExpiryService, "explicit", name) })
+ }
+}
+
+func rdsExpiryArgs(string) []string {
+ return []string{"--payment", "partial-upfront", "--target-coverage", strconv.Itoa(rdsExpiryTarget), "--rebuy-window-days", "30"}
+}
+
+func assertRDSExpiryCSV(t *testing.T, output, name, logs string) {
+ t.Helper()
+ s := rdsExpiryCase(name)
+ if s.logLine != "" {
+ require.Contains(t, logs, s.logLine)
+ }
+ data, err := os.ReadFile(output)
+ if countNonZero(s.want) == 0 {
+ require.ErrorIs(t, err, os.ErrNotExist, "nothing to buy must write no CSV; logs=%s", logs)
+ require.Contains(t, logs, "0 recommendations selected", logs)
+ return
+ }
+ require.NoError(t, err, logs)
+ rows, err := csv.NewReader(bytes.NewReader(data)).ReadAll()
+ require.NoError(t, err)
+ t.Logf("actual synthetic RDS expiry CSV scenario=%s rows=%v", name, rows)
+ for _, failure := range []string{"Failed to fetch", "incomplete AWS", "Could not fetch", "failed to fetch", "WARNING:", "catalog fetch"} {
+ require.NotContains(t, logs, failure)
+ }
+ if s.unusable > 0 {
+ require.Contains(t, logs, fmt.Sprintf("%d RDS reserved instances expiring within 30 days could not be applied to family sizing", s.unusable), logs)
+ } else {
+ require.NotContains(t, logs, "could not be applied to family sizing", logs)
+ }
+ byAccount := make(map[string]map[string]string)
+ total := 0
+ for _, row := range rows[1 : len(rows)-1] {
+ columns := make(map[string]string)
+ for i, header := range rows[0] {
+ columns[header] = row[i]
+ }
+ require.NotContains(t, byAccount, columns["Account"], "duplicate account row")
+ byAccount[columns["Account"]] = columns
+ }
+ for i, rec := range s.recs {
+ columns, present := byAccount[rdsExpiryAccountIDs[rec.account]]
+ count := s.want[i]
+ if count == 0 {
+ require.False(t, present, "scenario=%s rec %d must be dropped (parent behavior); rows=%v", name, i, rows)
+ continue
+ }
+ require.True(t, present, "scenario=%s rec %d missing; rows=%v\nlogs=%s", name, i, rows, logs)
+ want := map[string]string{
+ "Service": "rds", "Region": "us-east-1", "ResourceType": rec.class, "Term": "1yr", "PaymentOption": "partial-upfront",
+ "Count": strconv.Itoa(count), "ExistingCoverage": fmt.Sprintf("%.1f", s.existing),
+ "UpfrontPayment": fmt.Sprintf("%.2f", float64(count)*100), "EstimatedSavings": fmt.Sprintf("%.2f", float64(count)*100),
+ "RecurringMonthlyCost": fmt.Sprintf("%.2f", float64(count)*10), "Success": "true", "Error": "",
+ }
+ for field, value := range want {
+ require.Equal(t, value, columns[field], "scenario=%s rec=%d field=%s (parent count %d)", name, i, field, s.parent[i])
+ }
+ total += count
+ }
+ require.Len(t, byAccount, countNonZero(s.want), "unexpected extra rows: %v", rows)
+ if total > 0 {
+ last := rows[len(rows)-1]
+ require.Equal(t, "TOTAL", last[0])
+ for i, header := range rows[0] {
+ switch header {
+ case "Count":
+ require.Equal(t, strconv.Itoa(total), last[i])
+ case "UpfrontPayment", "EstimatedSavings":
+ require.Equal(t, fmt.Sprintf("%.2f", float64(total)*100), last[i])
+ case "RecurringMonthlyCost":
+ require.Equal(t, fmt.Sprintf("%.2f", float64(total)*10), last[i])
+ }
+ }
+ }
+}
+
+func countNonZero(values []int) int {
+ n := 0
+ for _, v := range values {
+ if v != 0 {
+ n++
+ }
+ }
+ return n
+}
+
+func (p *completenessProxy) respondRDSExpiry(req *http.Request, body []byte) *http.Response {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ op := req.Header.Get("X-Amz-Target")
+ values, err := url.ParseQuery(string(body))
+ if op == "" {
+ require.NoError(p.t, err)
+ op = values.Get("Action")
+ }
+ p.requests[req.Host+"/"+op]++
+ if req.Method != http.MethodPost || req.URL.Path != "/" || req.URL.RawQuery != "" {
+ p.t.Errorf("unexpected rds expiry request %s %s", req.Method, req.URL)
+ }
+ status, contentType, payload := p.rdsExpiryResponse(req.Host, op, body, values)
+ return &http.Response{StatusCode: status, ProtoMajor: 1, ProtoMinor: 1,
+ Header: http.Header{"Content-Type": []string{contentType}},
+ Body: io.NopCloser(strings.NewReader(payload)), ContentLength: int64(len(payload))}
+}
+
+func (p *completenessProxy) rdsExpiryResponse(host, op string, body []byte, values url.Values) (int, string, string) {
+ s := rdsExpiryCase(p.details)
+ switch {
+ case host == "ce.us-east-1.amazonaws.com" && op == "AWSInsightsIndexService.GetReservationPurchaseRecommendation":
+ var request map[string]any
+ require.NoError(p.t, json.Unmarshal(body, &request))
+ require.Equal(p.t, "Amazon Relational Database Service", request["Service"])
+ details := make([]map[string]any, 0, len(s.recs))
+ for _, rec := range s.recs {
+ details = append(details, map[string]any{
+ "AccountId": rdsExpiryAccountIDs[rec.account],
+ "RecommendedNumberOfInstancesToPurchase": strconv.Itoa(rec.count), "AverageNumberOfInstancesUsedPerHour": "1",
+ "UpfrontCost": strconv.Itoa(rec.count * 100), "EstimatedMonthlyOnDemandCost": strconv.Itoa(rec.count * 200),
+ "EstimatedMonthlySavingsAmount": strconv.Itoa(rec.count * 100), "RecurringStandardMonthlyCost": strconv.Itoa(rec.count * 10),
+ "InstanceDetails": map[string]any{"RDSInstanceDetails": map[string]string{
+ "InstanceType": rec.class, "Region": "us-east-1", "DatabaseEngine": "MySQL", "DeploymentOption": "Single-AZ",
+ }},
+ })
+ }
+ encoded, err := json.Marshal(map[string]any{"Recommendations": []any{map[string]any{"RecommendationDetails": details}}})
+ require.NoError(p.t, err)
+ return 200, "application/x-amz-json-1.1", string(encoded)
+ case host == "ce.us-east-1.amazonaws.com" && op == "AWSInsightsIndexService.GetReservationCoverage":
+ return 200, "application/x-amz-json-1.1", p.rdsExpiryCoverage(s, body)
+ case host == "organizations.us-east-1.amazonaws.com" && op == "AWSOrganizationsV20161128.DescribeAccount":
+ var request map[string]string
+ require.NoError(p.t, json.Unmarshal(body, &request))
+ require.Contains(p.t, rdsExpiryAccountIDs, request["AccountId"])
+ p.expiryAccounts = append(p.expiryAccounts, request["AccountId"])
+ return 200, "application/x-amz-json-1.1", fmt.Sprintf(`{"Account":{"Id":%q,"Name":"rds-expiry-account"}}`, request["AccountId"])
+ case host == "rds.us-east-1.amazonaws.com" && op == "DescribeReservedDBInstances":
+ require.Equal(p.t, "2014-10-31", values.Get("Version"))
+ return 200, "text/xml", rdsExpiryInventory(s)
+ }
+ p.t.Errorf("unexpected rds expiry operation %s %s", host, op)
+ return 403, "text/plain", "fixture rejected operation"
+}
+
+func rdsExpiryInventory(s rdsExpiryScenario) string {
+ var items strings.Builder
+ for i, ri := range s.ris {
+ // A one-year reservation ending endDays from now.
+ start := time.Now().AddDate(0, 0, ri.endDays-365).UTC().Format(time.RFC3339)
+ if ri.startedDaysAgo > 0 {
+ start = time.Now().AddDate(0, 0, -ri.startedDaysAgo).UTC().Format(time.RFC3339)
+ }
+ fmt.Fprintf(&items, `rds-expiry-%d%s%s%t%dactive%s31536000`,
+ i, ri.class, ri.engine, ri.multiAZ, ri.count, start)
+ }
+ return `` +
+ items.String() + ``
+}
+
+func (p *completenessProxy) rdsExpiryCoverage(s rdsExpiryScenario, body []byte) string {
+ var request struct {
+ GroupBy []map[string]string
+ Filter struct {
+ And []struct {
+ Dimensions struct {
+ Key string
+ Values []string
+ }
+ }
+ }
+ }
+ require.NoError(p.t, json.Unmarshal(body, &request))
+ filters := make(map[string][]string)
+ for _, expression := range request.Filter.And {
+ filters[expression.Dimensions.Key] = expression.Dimensions.Values
+ }
+ require.Len(p.t, filters["SERVICE"], 1)
+ service, engine := filters["SERVICE"][0], ""
+ key := service
+ if service == "Amazon Relational Database Service" {
+ require.Len(p.t, filters["DATABASE_ENGINE"], 1)
+ engine = filters["DATABASE_ENGINE"][0]
+ key += "/" + engine
+ }
+ p.expiryCoverage = append(p.expiryCoverage, key)
+ if engine == "" {
+ return `{"CoveragesByTime":[]}`
+ }
+ var groups []any
+ for _, usage := range s.usage {
+ if usage.engine != engine {
+ continue
+ }
+ groups = append(groups, map[string]any{
+ "Attributes": map[string]string{"instanceType": usage.class, "deploymentOption": "Single-AZ"},
+ "Coverage": map[string]any{"CoverageHours": map[string]string{
+ "CoverageHoursPercentage": strconv.FormatFloat(usage.pct, 'g', -1, 64),
+ "TotalRunningHours": strconv.FormatFloat(usage.avg*720, 'g', -1, 64),
+ }},
+ })
+ }
+ if groups == nil {
+ return `{"CoveragesByTime":[]}`
+ }
+ payload, err := json.Marshal(map[string]any{"CoveragesByTime": []any{map[string]any{"Groups": groups}}})
+ require.NoError(p.t, err)
+ return string(payload)
+}
+
+func (p *completenessProxy) assertRDSExpiryRequests(t *testing.T) {
+ t.Helper()
+ p.handlers.Wait()
+ for op, n := range p.requests {
+ require.NotContains(t, op, "PurchaseReservedDBInstancesOffering", "dry run must not purchase")
+ t.Logf("rds expiry op %s x%d", op, n)
+ }
+ require.Equal(t, 1, p.requests["ce.us-east-1.amazonaws.com/AWSInsightsIndexService.GetReservationPurchaseRecommendation"])
+ require.Equal(t, 12, p.requests["ce.us-east-1.amazonaws.com/AWSInsightsIndexService.GetReservationCoverage"])
+ require.Positive(t, p.requests["rds.us-east-1.amazonaws.com/DescribeReservedDBInstances"])
+}
diff --git a/cmd/recommendation_completeness_proxy_test.go b/cmd/recommendation_completeness_proxy_test.go
index d4926dace..8c9359a8a 100644
--- a/cmd/recommendation_completeness_proxy_test.go
+++ b/cmd/recommendation_completeness_proxy_test.go
@@ -62,7 +62,7 @@ func newCompletenessProxy(t *testing.T, dir, regions, details, service string) (
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
- if service == "ec2" {
+ if service == "ec2" || service == rdsExpiryService {
template.DNSNames = append(template.DNSNames, "organizations.us-east-1.amazonaws.com")
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
@@ -84,7 +84,7 @@ func (p *completenessProxy) serveConnect(w http.ResponseWriter, r *http.Request)
p.handlers.Add(1)
defer p.handlers.Done()
allowed := r.Host == "ce.us-east-1.amazonaws.com:443" || r.Host == "ec2.us-east-1.amazonaws.com:443" || r.Host == "rds.us-east-1.amazonaws.com:443" || r.Host == "savingsplans.amazonaws.com:443"
- allowed = allowed || (p.service == "ec2" && r.Host == "organizations.us-east-1.amazonaws.com:443")
+ allowed = allowed || ((p.service == "ec2" || p.service == rdsExpiryService) && r.Host == "organizations.us-east-1.amazonaws.com:443")
if r.Method != http.MethodConnect || !allowed {
p.t.Errorf("unexpected CONNECT %s %s", r.Method, r.Host)
http.Error(w, "forbidden", http.StatusForbidden)
@@ -142,6 +142,9 @@ func (p *completenessProxy) respond(req *http.Request) *http.Response {
if p.service == "ec2" {
return p.respondReservationExpiry(req, body)
}
+ if p.service == rdsExpiryService {
+ return p.respondRDSExpiry(req, body)
+ }
op := req.Header.Get("X-Amz-Target")
if req.Host == "savingsplans.amazonaws.com" && op == "" {
op = strings.TrimPrefix(req.URL.Path, "/")
@@ -253,6 +256,10 @@ func (p *completenessProxy) assertRequests(t *testing.T) {
p.assertReservationExpiryRequests(t)
return
}
+ if p.service == rdsExpiryService {
+ p.assertRDSExpiryRequests(t)
+ return
+ }
if p.service != "rds" {
p.assertSPRequests(t)
return
diff --git a/cmd/recommendation_completeness_test.go b/cmd/recommendation_completeness_test.go
index 6ca2ccebb..1f3b8a6c2 100644
--- a/cmd/recommendation_completeness_test.go
+++ b/cmd/recommendation_completeness_test.go
@@ -76,6 +76,8 @@ func runCompletenessScenario(t *testing.T, service, regionMode, details string)
switch service {
case "ec2":
assertReservationExpiryCSV(t, output, details, logs)
+ case rdsExpiryService:
+ assertRDSExpiryCSV(t, output, details, logs)
case "rds":
assertCompletenessDiagnostics(t, regionMode, details, stdout.String(), stderr.String())
assertCompletenessCSV(t, output, details, logs)
@@ -94,7 +96,11 @@ func runCompletenessCommandChild(t *testing.T) {
transport.TLSClientConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
http.DefaultTransport = transport
t.Cleanup(transport.CloseIdleConnections)
- args := []string{"--services", os.Getenv("CUDLY_COMPLETENESS_SERVICE"), "--term", "1", "--include-extended-support", "--coverage", "100",
+ services := os.Getenv("CUDLY_COMPLETENESS_SERVICE")
+ if services == rdsExpiryService {
+ services = "rds"
+ }
+ args := []string{"--services", services, "--term", "1", "--include-extended-support", "--coverage", "100",
"--output", os.Getenv("CUDLY_COMPLETENESS_OUTPUT"), "--audit-log", os.Getenv("CUDLY_COMPLETENESS_AUDIT")}
if os.Getenv("CUDLY_COMPLETENESS_REGIONS") == "explicit" {
args = append(args, "--regions", "us-east-1")
@@ -102,6 +108,9 @@ func runCompletenessCommandChild(t *testing.T) {
if os.Getenv("CUDLY_COMPLETENESS_SERVICE") == "ec2" {
args = append(args, reservationExpiryArgs(os.Getenv("CUDLY_COMPLETENESS_DETAILS"))...)
}
+ if os.Getenv("CUDLY_COMPLETENESS_SERVICE") == rdsExpiryService {
+ args = append(args, rdsExpiryArgs(os.Getenv("CUDLY_COMPLETENESS_DETAILS"))...)
+ }
rootCmd.SetArgs(args)
require.NoError(t, rootCmd.Execute())
}
diff --git a/go.mod b/go.mod
index 377a8594a..8389b9b41 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-20261009181525-30bf383795db
- github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012180926-2c1e87b723ab
+ 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/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 ee996a239..f857c05f1 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-20261009181525-30bf383795db h1:d/eTZP31KewqE049NlQM3nZIUygRudHJ25Im2ibJk58=
-github.com/LeanerCloud/cloud-commitments-go/pkg v0.0.0-20261009181525-30bf383795db/go.mod h1:8RTmzPazA8IeVZfn7wNaU7ILx0VK5eHZQozO21Mvtq8=
-github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012180926-2c1e87b723ab h1:E6cZ5WllhWGR8EgONN8MwWaGD3qLCCwYIXWgIysnfYU=
-github.com/LeanerCloud/cloud-commitments-go/providers/aws v0.0.0-20261012180926-2c1e87b723ab/go.mod h1:C0ildZeRds0aRvDZp49MLY0kFWh/4FiM4lPWWWd3HFA=
+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/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=