Skip to content

Commit 210707f

Browse files
committed
test(coverage): assert CE lookback window width + add --rec-lookback-period flag (refs #360)
Gap 1: extend mockCoverageCE to capture the last CE TimePeriod and add TestGetRICoverageMap_LookbackWindowWidth, which parses Start/End YYYY-MM-DD strings and asserts end-start == lookbackDays. Test fails if the window math regresses to a hardcoded value. Gap 2: replace the tautological "custom lookback stored in Config" subcase in TestFetchExistingCoverage_LookbackDays (which asserted a struct field equals what was just assigned) with a comment pointing to the new discriminating test. Gap 3: implement the second flag from issue #360: --rec-lookback-period (7d/30d/60d, default 7d) controls LookbackPeriodInDays in GetReservationPurchaseRecommendation. Wired via Config.RecLookbackPeriod through fetchRecommendationsForRegion (CLI path) and Client.SetRecLookbackPeriod (discovery/UI path). Validated in validateFlags. TestSetRecLookbackPeriod_ReachesGetReservationPurchaseRecommendation asserts all three enum values reach the actual CE input. TestValidateRecLookbackPeriod guards the validation logic.
1 parent 3405060 commit 210707f

10 files changed

Lines changed: 186 additions & 8 deletions

File tree

‎cmd/main.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ type Config struct {
9494
// 100% coverage). Zero (default) keeps all pools. SPs and recs
9595
// without a per-hour signal pass through unfiltered.
9696
MinPoolSize float64
97+
// RecLookbackPeriod controls the LookbackPeriodInDays passed to
98+
// GetReservationPurchaseRecommendation. Valid values: "7d", "30d", "60d".
99+
// A longer window smooths seasonal spikes; a shorter window weights recent
100+
// demand more heavily. Default "7d" matches the CE console default.
101+
RecLookbackPeriod string
97102
}
98103

99104
func main() {
@@ -171,6 +176,10 @@ func init() {
171176
"below this threshold. Useful with --target-coverage to skip tiny pools "+
172177
"that integer arithmetic forces above target (e.g. avg=1 cannot hit 80%%). "+
173178
"Default 0 = no filter.")
179+
rootCmd.Flags().StringVar(&toolCfg.RecLookbackPeriod, "rec-lookback-period", "7d",
180+
"Historical window for GetReservationPurchaseRecommendation. "+
181+
"Valid values: 7d, 30d, 60d. A longer window smooths seasonal spikes; "+
182+
"a shorter window weights recent demand more heavily. Default 7d.")
174183
}
175184

176185
// Package-level Config that cobra flags bind to

‎cmd/multi_service.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ func runToolMultiService(ctx context.Context, cfg Config) {
110110

111111
accountCache := NewAccountAliasCache(awsCfg)
112112
recClient := awsprovider.NewRecommendationsClient(awsCfg)
113+
if adapter, ok := recClient.(*awsprovider.RecommendationsClientAdapter); ok && cfg.RecLookbackPeriod != "" {
114+
adapter.SetRecLookbackPeriod(cfg.RecLookbackPeriod)
115+
}
113116
engineData := fetchEngineVersionData(ctx, cfg)
114117

115118
// Fetch existing-RI coverage so --target-coverage can subtract what

‎cmd/multi_service_coverage_test.go‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -797,9 +797,11 @@ func TestFetchExistingCoverage_LookbackDays(t *testing.T) {
797797
assert.Nil(t, got, "non-AWS provider must return nil (no CE integration)")
798798
})
799799

800-
t.Run("custom lookback stored in Config", func(t *testing.T) {
801-
cfg := Config{TargetCoverage: 80, CoverageLookbackDays: 60, Regions: []string{"us-east-1"}}
802-
// CoverageLookbackDays field value is preserved in the struct.
803-
assert.Equal(t, 60, cfg.CoverageLookbackDays)
804-
})
800+
// The previous "custom lookback stored in Config" subcase asserted only that
801+
// a struct field equals what was just assigned -- a tautology that passes even
802+
// if CoverageLookbackDays is never forwarded to GetRICoverageMap. The real
803+
// assertion -- that the lookback value reaches the CE TimePeriod -- is covered
804+
// by TestGetRICoverageMap_LookbackWindowWidth in
805+
// providers/aws/recommendations/coverage_test.go, which directly verifies
806+
// end-start == lookbackDays on the actual CE input. No redundant subcase here.
805807
}

‎cmd/multi_service_helpers.go‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -421,12 +421,16 @@ func fetchRecommendationsForRegion(
421421
termStr = "3yr"
422422
}
423423

424+
lookback := cfg.RecLookbackPeriod
425+
if lookback == "" {
426+
lookback = "7d"
427+
}
424428
params := common.RecommendationParams{
425429
Service: service,
426430
Region: region,
427431
PaymentOption: cfg.PaymentOption,
428432
Term: termStr,
429-
LookbackPeriod: "7d",
433+
LookbackPeriod: lookback,
430434
// Savings Plans specific filters
431435
IncludeSPTypes: cfg.IncludeSPTypes,
432436
ExcludeSPTypes: cfg.ExcludeSPTypes,

‎cmd/validators.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,19 @@ func validateFlags(cmd *cobra.Command, args []string) error {
2929
return err
3030
}
3131

32+
if err := validateRecLookbackPeriod(); err != nil {
33+
return err
34+
}
35+
36+
return nil
37+
}
38+
39+
// validateRecLookbackPeriod validates the --rec-lookback-period flag.
40+
func validateRecLookbackPeriod() error {
41+
valid := map[string]bool{"7d": true, "30d": true, "60d": true}
42+
if !valid[toolCfg.RecLookbackPeriod] {
43+
return fmt.Errorf("invalid rec-lookback-period %q: must be one of 7d, 30d, 60d", toolCfg.RecLookbackPeriod)
44+
}
3245
return nil
3346
}
3447

‎cmd/validators_test.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,39 @@ func TestValidateCoverageLookbackDays(t *testing.T) {
540540
})
541541
}
542542
}
543+
544+
// TestValidateRecLookbackPeriod verifies that validateRecLookbackPeriod accepts
545+
// the three valid values and rejects anything else, including empty string.
546+
func TestValidateRecLookbackPeriod(t *testing.T) {
547+
tests := []struct {
548+
name string
549+
period string
550+
wantErr bool
551+
errSubstr string
552+
}{
553+
{name: "7d valid", period: "7d", wantErr: false},
554+
{name: "30d valid", period: "30d", wantErr: false},
555+
{name: "60d valid", period: "60d", wantErr: false},
556+
{name: "empty rejected", period: "", wantErr: true, errSubstr: "invalid rec-lookback-period"},
557+
{name: "14d rejected", period: "14d", wantErr: true, errSubstr: "invalid rec-lookback-period"},
558+
{name: "90d rejected", period: "90d", wantErr: true, errSubstr: "invalid rec-lookback-period"},
559+
{name: "SEVEN_DAYS rejected", period: "SEVEN_DAYS", wantErr: true, errSubstr: "invalid rec-lookback-period"},
560+
}
561+
for _, tt := range tests {
562+
t.Run(tt.name, func(t *testing.T) {
563+
origCfg := toolCfg
564+
defer func() { toolCfg = origCfg }()
565+
toolCfg.RecLookbackPeriod = tt.period
566+
err := validateRecLookbackPeriod()
567+
if tt.wantErr {
568+
if err == nil {
569+
t.Errorf("validateRecLookbackPeriod() expected error containing %q, got nil", tt.errSubstr)
570+
} else if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
571+
t.Errorf("validateRecLookbackPeriod() error = %v, want substring %q", err, tt.errSubstr)
572+
}
573+
} else if err != nil {
574+
t.Errorf("validateRecLookbackPeriod() unexpected error = %v", err)
575+
}
576+
})
577+
}
578+
}

‎providers/aws/recommendations/client.go‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ type Client struct {
4949
// lazily once per Client lifetime via sync.Once (one DescribeInstanceTypes
5050
// fan-out per scheduler tick).
5151
skuCatalog skuCatalog
52+
53+
// recLookbackPeriod is forwarded to GetReservationPurchaseRecommendation
54+
// as LookbackPeriodInDays. Defaults to "7d" when empty.
55+
recLookbackPeriod string
5256
}
5357

5458
// NewClient creates a new recommendations client
@@ -94,7 +98,7 @@ func (c *Client) SetInstanceTypePagerFactory(f func() InstanceTypePager) {
9498
// instanceTypeLookup returns the cached SKU entry for instanceType.
9599
// On the first call the catalogue is built by calling the pager factory.
96100
// ok=false when no factory is configured, the catalogue fetch failed, or
97-
// the instance type was not in the catalogue — the caller falls back to
101+
// the instance type was not in the catalogue -- the caller falls back to
98102
// VCPU=0/MemoryGB=0 (graceful-degradation contract from Azure PR #810).
99103
func (c *Client) instanceTypeLookup(ctx context.Context, instanceType string) (instanceTypeSKUEntry, bool) {
100104
if c.instanceTypePagerFactory == nil {
@@ -103,6 +107,13 @@ func (c *Client) instanceTypeLookup(ctx context.Context, instanceType string) (i
103107
return c.skuCatalog.lookup(ctx, instanceType, c.instanceTypePagerFactory)
104108
}
105109

110+
// SetRecLookbackPeriod configures the LookbackPeriodInDays used by
111+
// GetRecommendationsForService. Valid values: "7d", "30d", "60d".
112+
// An empty or unrecognised value falls back to "7d" at call time.
113+
func (c *Client) SetRecLookbackPeriod(period string) {
114+
c.recLookbackPeriod = period
115+
}
116+
106117
// GetRecommendations fetches Reserved Instance recommendations for any service
107118
func (c *Client) GetRecommendations(ctx context.Context, params common.RecommendationParams) ([]common.Recommendation, error) {
108119
// Handle Savings Plans separately — they use a different Cost Explorer API
@@ -248,11 +259,15 @@ func (c *Client) GetRecommendationsForService(ctx context.Context, service commo
248259
return nil, ctx.Err()
249260
}
250261
attempts++
262+
lookback := c.recLookbackPeriod
263+
if lookback == "" {
264+
lookback = "7d"
265+
}
251266
params := common.RecommendationParams{
252267
Service: service,
253268
PaymentOption: payment,
254269
Term: term,
255-
LookbackPeriod: "7d",
270+
LookbackPeriod: lookback,
256271
Region: "",
257272
}
258273
recs, err := c.GetRecommendations(ctx, params)

‎providers/aws/recommendations/client_test.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -819,3 +819,37 @@ func TestGetRecommendations_RI_PaginationCapError(t *testing.T) {
819819
assert.Equal(t, maxRecommendationPages, mock.calls,
820820
"must stop exactly at the cap, not one page later")
821821
}
822+
823+
// TestSetRecLookbackPeriod_ReachesGetReservationPurchaseRecommendation asserts
824+
// that SetRecLookbackPeriod propagates the chosen period into the
825+
// LookbackPeriodInDays field of every GetReservationPurchaseRecommendation
826+
// call issued by GetRecommendationsForService (refs #360). This test is
827+
// intentionally discriminating: it would fail if recLookbackPeriod were
828+
// ignored and the hardcoded "7d" default were sent instead.
829+
func TestSetRecLookbackPeriod_ReachesGetReservationPurchaseRecommendation(t *testing.T) {
830+
cases := []struct {
831+
period string
832+
wantEnum types.LookbackPeriodInDays
833+
}{
834+
{"7d", types.LookbackPeriodInDaysSevenDays},
835+
{"30d", types.LookbackPeriodInDaysThirtyDays},
836+
{"60d", types.LookbackPeriodInDaysSixtyDays},
837+
}
838+
for _, tc := range cases {
839+
t.Run(tc.period, func(t *testing.T) {
840+
mock := &mockCostExplorerAPI{
841+
riRecommendations: &costexplorer.GetReservationPurchaseRecommendationOutput{},
842+
}
843+
client := NewClientWithAPI(mock, "us-east-1")
844+
client.SetRecLookbackPeriod(tc.period)
845+
846+
_, err := client.GetRecommendationsForService(context.Background(), common.ServiceEC2)
847+
require.NoError(t, err)
848+
require.NotEmpty(t, mock.riCalls, "GetReservationPurchaseRecommendation must have been called")
849+
for i, call := range mock.riCalls {
850+
assert.Equal(t, tc.wantEnum, call.LookbackPeriodInDays,
851+
"call[%d]: LookbackPeriodInDays must match --rec-lookback-period=%s", i, tc.period)
852+
}
853+
})
854+
}
855+
}

‎providers/aws/recommendations/coverage_test.go‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package recommendations
33
import (
44
"context"
55
"testing"
6+
"time"
67

78
"github.com/aws/aws-sdk-go-v2/aws"
89
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
@@ -15,15 +16,21 @@ import (
1516

1617
// mockCoverageCE extends the test mock with a configurable GetReservationCoverage
1718
// response so the coverage path can be exercised without hitting AWS.
19+
// lastTimePeriod captures the TimePeriod from the most recent call so tests can
20+
// assert that the CE window matches the requested lookbackDays.
1821
type mockCoverageCE struct {
1922
mockCostExplorerAPI
2023
coverageOutput *costexplorer.GetReservationCoverageOutput
2124
coverageError error
2225
coverageCalls int
26+
lastTimePeriod *types.DateInterval
2327
}
2428

2529
func (m *mockCoverageCE) GetReservationCoverage(ctx context.Context, params *costexplorer.GetReservationCoverageInput, optFns ...func(*costexplorer.Options)) (*costexplorer.GetReservationCoverageOutput, error) {
2630
m.coverageCalls++
31+
if params != nil {
32+
m.lastTimePeriod = params.TimePeriod
33+
}
2734
if m.coverageError != nil {
2835
return nil, m.coverageError
2936
}
@@ -118,6 +125,55 @@ func TestGetRICoverageMap_LookbackDefault(t *testing.T) {
118125
assert.Equal(t, wantCalls, mock.coverageCalls)
119126
}
120127

128+
// TestGetRICoverageMap_LookbackWindowWidth asserts that the CE TimePeriod
129+
// sent to GetReservationCoverage spans exactly lookbackDays calendar days.
130+
// This test is deliberately discriminating: it parses the YYYY-MM-DD Start
131+
// and End strings and verifies end-start == N days so a regression in the
132+
// window math (e.g. reverting to a hardcoded 30) causes a test failure.
133+
func TestGetRICoverageMap_LookbackWindowWidth(t *testing.T) {
134+
cases := []struct {
135+
name string
136+
lookbackDays int
137+
}{
138+
{"14-day window", 14},
139+
{"60-day window", 60},
140+
}
141+
for _, tc := range cases {
142+
t.Run(tc.name, func(t *testing.T) {
143+
mock := &mockCoverageCE{coverageOutput: &costexplorer.GetReservationCoverageOutput{}}
144+
client := NewClientWithAPI(mock, "us-east-1")
145+
146+
before := time.Now().UTC()
147+
_, err := client.GetRICoverageMap(context.Background(), tc.lookbackDays, []string{"us-east-1"})
148+
after := time.Now().UTC()
149+
require.NoError(t, err)
150+
require.NotNil(t, mock.lastTimePeriod, "GetReservationCoverage must have been called with a TimePeriod")
151+
require.NotNil(t, mock.lastTimePeriod.Start)
152+
require.NotNil(t, mock.lastTimePeriod.End)
153+
154+
start, err := time.Parse("2006-01-02", aws.ToString(mock.lastTimePeriod.Start))
155+
require.NoError(t, err, "TimePeriod.Start must be a valid YYYY-MM-DD date")
156+
end, err := time.Parse("2006-01-02", aws.ToString(mock.lastTimePeriod.End))
157+
require.NoError(t, err, "TimePeriod.End must be a valid YYYY-MM-DD date")
158+
159+
// CE dates are truncated to day; allow for midnight-boundary
160+
// skew of at most 1 day when the test runs near UTC midnight.
161+
spanDays := int(end.Sub(start).Hours() / 24)
162+
assert.Equal(t, tc.lookbackDays, spanDays,
163+
"CE TimePeriod must span exactly lookbackDays days (start=%s end=%s)",
164+
aws.ToString(mock.lastTimePeriod.Start),
165+
aws.ToString(mock.lastTimePeriod.End))
166+
167+
// Sanity: the End date must fall within the test's execution window.
168+
endUTC := end
169+
assert.True(t, !endUTC.Before(before.Truncate(24*time.Hour)),
170+
"TimePeriod.End must not be before test start")
171+
assert.True(t, !endUTC.After(after.Add(24*time.Hour)),
172+
"TimePeriod.End must not be more than a day after test completion")
173+
})
174+
}
175+
}
176+
121177
// TestApplyCoverageMapToRecommendations covers the org-wide pool matching:
122178
// recs look up by (region, instance_type, [engine, deployment]) so any
123179
// linked account in the org sees the same coverage % for the same pool

‎providers/aws/service_client.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ func (r *RecommendationsClientAdapter) GetRICoverageMap(ctx context.Context, loo
174174
return r.client.GetRICoverageMap(ctx, lookbackDays, regions)
175175
}
176176

177+
// SetRecLookbackPeriod configures the LookbackPeriodInDays forwarded to
178+
// GetReservationPurchaseRecommendation. Valid values: "7d", "30d", "60d".
179+
func (r *RecommendationsClientAdapter) SetRecLookbackPeriod(period string) {
180+
r.client.SetRecLookbackPeriod(period)
181+
}
182+
177183
// NewRecommendationsClientDirect creates a new recommendations client returning the concrete type
178184
// (needed for GetRIUtilization which is not part of the generic provider interface).
179185
func NewRecommendationsClientDirect(cfg aws.Config) *RecommendationsClientAdapter {

0 commit comments

Comments
 (0)