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
5 changes: 3 additions & 2 deletions providers/aws/ladder/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,14 +213,15 @@ func parseSPDate(field string, value *string, planID string) (time.Time, error)
// increasing unique UTC days) is guaranteed by the recommendations contract
// and preserved verbatim by the index-for-index copy.
type onDemandSeriesAdapter struct {
client *recommendations.Client
client *recommendations.Client
accountID string
}

// GetOnDemandSeries fetches the daily on-demand cost series from CE and maps
// each dated entry to a DailyPoint. Errors propagate unchanged (no silent
// fallback); an empty series is already rejected inside the client.
func (a *onDemandSeriesAdapter) GetOnDemandSeries(ctx context.Context, region string, lookbackDays int) ([]DailyPoint, error) {
costs, err := a.client.GetOnDemandSeries(ctx, region, lookbackDays)
costs, err := a.client.GetOnDemandSeriesForAccount(ctx, a.accountID, region, lookbackDays)
if err != nil {
return nil, err
}
Expand Down
16 changes: 9 additions & 7 deletions providers/aws/ladder/adapters_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,9 @@ func (m *ladderCEMock) GetCostAndUsage(
// SCREAMING_SNAKE lookup, the r.Total lookup would miss this key and the
// mapping test below would fail with a missing-metric error.
func ceDailyRow(dateStr, amount string) cetypes.ResultByTime {
day, _ := time.Parse(time.DateOnly, dateStr)
return cetypes.ResultByTime{
TimePeriod: &cetypes.DateInterval{Start: aws.String(dateStr), End: aws.String(dateStr)},
TimePeriod: &cetypes.DateInterval{Start: aws.String(dateStr), End: aws.String(day.AddDate(0, 0, 1).Format(time.DateOnly))},
Total: map[string]cetypes.MetricValue{
"UnblendedCost": {Amount: aws.String(amount)},
},
Expand All @@ -488,29 +489,30 @@ func ceDailyRow(dateStr, amount string) cetypes.ResultByTime {
// []recommendations.DailyCost -> []DailyPoint mapping: dates and values are
// copied index-for-index, oldest-first.
func TestOnDemandSeriesAdapter_MapsDailyCostToDailyPoint(t *testing.T) {
day := time.Now().UTC().Truncate(24*time.Hour).AddDate(0, 0, -2)
mock := &ladderCEMock{costAndUsage: &costexplorer.GetCostAndUsageOutput{
ResultsByTime: []cetypes.ResultByTime{
ceDailyRow("2026-01-02", "480"), // $20/hr, newer
ceDailyRow("2026-01-01", "240"), // $10/hr, older
ceDailyRow(day.AddDate(0, 0, 1).Format(time.DateOnly), "480"),
ceDailyRow(day.Format(time.DateOnly), "240"),
},
}}
adapter := &onDemandSeriesAdapter{client: recommendations.NewClientWithAPI(mock, testRegion)}
adapter := &onDemandSeriesAdapter{client: recommendations.NewClientWithAPI(mock, testRegion), accountID: "fixture-account"}

points, err := adapter.GetOnDemandSeries(context.Background(), testRegion, 7)

require.NoError(t, err)
require.Len(t, points, 2)
assert.Equal(t, time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), points[0].Date, "oldest first")
assert.Equal(t, day, points[0].Date, "oldest first")
assert.InDelta(t, 10.0, points[0].USDPerHour, 1e-9)
assert.Equal(t, time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), points[1].Date)
assert.Equal(t, day.AddDate(0, 0, 1), points[1].Date)
assert.InDelta(t, 20.0, points[1].USDPerHour, 1e-9)
}

// TestOnDemandSeriesAdapter_PropagatesError verifies errors pass through the
// adapter unchanged (no silent fallback).
func TestOnDemandSeriesAdapter_PropagatesError(t *testing.T) {
// Empty CE output -> the client errors on the empty series.
adapter := &onDemandSeriesAdapter{client: recommendations.NewClientWithAPI(&ladderCEMock{}, testRegion)}
adapter := &onDemandSeriesAdapter{client: recommendations.NewClientWithAPI(&ladderCEMock{}, testRegion), accountID: "fixture-account"}

_, err := adapter.GetOnDemandSeries(context.Background(), testRegion, 7)

Expand Down
23 changes: 14 additions & 9 deletions providers/aws/ladder/baseline.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,15 +141,8 @@ func utcDay(t time.Time) time.Time {
return t.UTC().Truncate(24 * time.Hour)
}

// validateInWindowCoverage rejects a series that does not have enough points
// falling INSIDE the lookback window [today-lookbackDays, today]. Counting the
// total number of points is insufficient: N points smeared across more than N
// days (e.g. 27 points spread over 53 days) would pass a naive count check
// while leaving the recent window sparsely covered, so the percentile would be
// computed over data that does not represent the requested window. Every date
// is normalized to its UTC calendar day (utcDay) before comparison.
//
// today must already be normalized via utcDay by the caller.
// Only trailing reporting lag is allowed; missing interior days are not zero.
// today must be midnight UTC, matching the collector's exclusive end.
func validateInWindowCoverage(points []DailyPoint, lookbackDays int, today time.Time) error {
windowStart := today.AddDate(0, 0, -lookbackDays)
inWindow := 0
Expand All @@ -165,6 +158,18 @@ func validateInWindowCoverage(points []DailyPoint, lookbackDays int, today time.
inWindow, lookbackDays, minRequired, lookbackDays, maxMissingDays,
)
}
if err := validateSeriesChronology(points); err != nil {
return err
}
for i, p := range points {
d := utcDay(p.Date)
if d.Before(windowStart) || !d.Before(today) {
return fmt.Errorf("on-demand series day %s is outside the requested window", d)
}
if !d.Equal(windowStart.AddDate(0, 0, i)) {
return fmt.Errorf("on-demand series has a missing day before %s", d)
}
}
return nil
}

Expand Down
182 changes: 182 additions & 0 deletions providers/aws/ladder/baseline_sdk_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
package ladder_test

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"sync/atomic"
"testing"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
"github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/LeanerCloud/cloud-commitments-go/pkg/ladder"
awsladder "github.com/LeanerCloud/cloud-commitments-go/providers/aws/ladder"
)

// Constructed CE responses exercise the public factory without cloud access.
func TestScopedBaselineParentCompatible(t *testing.T) {
for _, mode := range []string{"account", "reverse", "zero", "lag2", "lag3", "duplicate", "page-duplicate", "hole", "leading", "outside", "future", "bad-start", "nil-start", "nil-period", "nil-end", "bad-end", "short-period", "long-period", "backward-period", "empty", "all-zero", "missing-total", "missing-metric", "missing-amount", "bad-amount", "NaN", "Inf", "negative", "stale", "few", "provider-error"} {
t.Run(mode, func(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
assert.Equal(t, "AWSInsightsIndexService.GetCostAndUsage", r.Header.Get("X-Amz-Target"))
var in costexplorer.GetCostAndUsageInput
if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&in)) {
return
}
assert.Equal(t, types.GranularityDaily, in.Granularity)
assert.Equal(t, []string{"UnblendedCost"}, in.Metrics)
start, err := time.Parse(time.DateOnly, aws.ToString(in.TimePeriod.Start))
assert.NoError(t, err)
assert.Equal(t, start.AddDate(0, 0, 30).Format(time.DateOnly), aws.ToString(in.TimePeriod.End))
dimensions := map[types.Dimension][]string{}
if in.Filter != nil {
for _, clause := range in.Filter.And {
if clause.Dimensions != nil {
dimensions[clause.Dimensions.Key] = clause.Dimensions.Values
}
}
}
matched := slices.Equal(dimensions[types.DimensionLinkedAccount], []string{"fixture-account"}) &&
slices.Equal(dimensions[types.DimensionRegion], []string{"eu-west-1"}) &&
slices.Equal(dimensions[types.DimensionService], []string{"Amazon Elastic Compute Cloud - Compute"}) &&
slices.Equal(dimensions[types.DimensionPurchaseType], []string{"On Demand Instances"})
rows := baselineRows(start, mode)
token := ""
if mode == "account" || mode == "reverse" || mode == "page-duplicate" {
switch aws.ToString(in.NextPageToken) {
case "":
rows, token = rows[:28], "second"
case "second":
rows = rows[28:]
if mode == "page-duplicate" {
rows = append(rows, baselineRows(start, "account")[0])
}
default:
t.Error("unexpected page token")
}
}
if mode == "reverse" {
slices.Reverse(rows)
}
if mode == "account" && !matched {
for i := range rows {
rows[i].Total["UnblendedCost"] = types.MetricValue{Amount: aws.String("2400")}
}
}
if mode == "provider-error" {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
assert.NoError(t, json.NewEncoder(w).Encode(map[string]any{"ResultsByTime": rows, "NextPageToken": token}))
}))
defer srv.Close()
endpoint, err := url.Parse(srv.URL)
require.NoError(t, err)
a, err := awsladder.NewFromConfig(aws.Config{Region: "eu-west-1", RetryMaxAttempts: 1,
Credentials: credentials.NewStaticCredentialsProvider("synthetic", "synthetic", ""),
HTTPClient: &http.Client{Transport: ceLoopback{endpoint: endpoint, transport: srv.Client().Transport}},
}, awsladder.Config{Region: "eu-west-1", AccountID: "fixture-account"})
require.NoError(t, err)
scope := ladder.Scope{Provider: common.ProviderAWS, AccountID: "wrong"}
_, err = a.GetUsageBaseline(context.Background(), scope, 30, 5)
require.Error(t, err)
assert.Zero(t, calls.Load())
scope.AccountID = "fixture-account"
_, err = a.GetUsageBaseline(context.Background(), scope, 0, 5)
require.Error(t, err)
_, err = a.GetUsageBaseline(context.Background(), scope, 30, 0)
require.Error(t, err)
assert.Zero(t, calls.Load())
bl, err := a.GetUsageBaseline(context.Background(), scope, 30, 5)
want := map[string]float64{"account": 2, "reverse": 2, "zero": 2, "lag2": 10, "lag3": 10}
if low, ok := want[mode]; ok {
require.NoError(t, err)
require.NotNil(t, bl.LowWaterUSDPerHour)
assert.InDelta(t, low, *bl.LowWaterUSDPerHour, 1e-9)
assert.Nil(t, bl.StableUSDPerHour)
assert.Equal(t, 30, bl.LookbackDays)
assert.Equal(t, 5.0, bl.Percentile)
assert.Equal(t, 10.0, bl.Series[1])
if mode == "zero" {
assert.Zero(t, bl.Series[0])
}
} else {
require.Error(t, err)
}
if mode == "account" || mode == "reverse" {
assert.Equal(t, int32(2), calls.Load())
}
})
}
}

func baselineRows(start time.Time, mode string) []types.ResultByTime {
rows := make([]types.ResultByTime, 30)
for i := range rows {
day, amount := start.AddDate(0, 0, i), "240"
if i >= 28 {
amount = "48"
}
rows[i] = types.ResultByTime{TimePeriod: &types.DateInterval{Start: aws.String(day.Format(time.DateOnly)), End: aws.String(day.AddDate(0, 0, 1).Format(time.DateOnly))}, Total: map[string]types.MetricValue{"UnblendedCost": {Amount: aws.String(amount), Unit: aws.String("USD")}}}
}
switch mode {
case "duplicate":
rows = append(rows, rows[0])
case "hole":
rows = append(rows[:10], rows[11:]...)
case "leading":
rows = rows[1:]
case "outside":
rows[0].TimePeriod.Start = aws.String(start.AddDate(0, 0, -1).Format(time.DateOnly))
rows[0].TimePeriod.End = aws.String(start.Format(time.DateOnly))
case "future":
rows[0].TimePeriod = &types.DateInterval{Start: aws.String(start.AddDate(0, 0, 30).Format(time.DateOnly)), End: aws.String(start.AddDate(0, 0, 31).Format(time.DateOnly))}
case "bad-start":
rows[0].TimePeriod.Start = aws.String("bad")
case "nil-start":
rows[0].TimePeriod.Start = nil
case "nil-period":
rows[0].TimePeriod = nil
case "nil-end":
rows[0].TimePeriod.End = nil
case "bad-end":
rows[0].TimePeriod.End = aws.String("bad")
case "short-period":
rows[0].TimePeriod.End = rows[0].TimePeriod.Start
case "long-period":
rows[0].TimePeriod.End = aws.String(start.AddDate(0, 0, 2).Format(time.DateOnly))
case "backward-period":
rows[0].TimePeriod.End = aws.String(start.AddDate(0, 0, -1).Format(time.DateOnly))
case "empty":
rows = nil
case "all-zero":
for i := range rows {
rows[i].Total["UnblendedCost"] = types.MetricValue{Amount: aws.String("0")}
}
case "missing-total":
rows[0].Total = nil
case "missing-metric":
rows[0].Total = map[string]types.MetricValue{}
case "missing-amount":
rows[0].Total["UnblendedCost"] = types.MetricValue{}
case "bad-amount", "NaN", "Inf", "negative", "zero":
amount := map[string]string{"bad-amount": "bad", "NaN": "NaN", "Inf": "Inf", "negative": "-1", "zero": "0"}[mode]
rows[0].Total["UnblendedCost"] = types.MetricValue{Amount: aws.String(amount)}
case "lag2", "lag3", "stale", "few":
rows = rows[:map[string]int{"lag2": 29, "lag3": 28, "stale": 27, "few": 6}[mode]]
}
return rows
}
2 changes: 1 addition & 1 deletion providers/aws/ladder/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func NewFromConfig(awsCfg aws.Config, cfg Config) (*AWSLadder, error) {
ec2Client, // riLister
&spListerAdapter{api: spSDKClient, region: cfg.Region}, // spLister (region-scoped)
recoClient, // riCoverageSource
&onDemandSeriesAdapter{client: recoClient}, // onDemandSeriesSource
&onDemandSeriesAdapter{client: recoClient, accountID: cfg.AccountID}, // onDemandSeriesSource
recoClient, // utilizationSource
&spCoverageAdapter{client: recoClient}, // spCoverageSource
&spUtilizationAdapter{client: recoClient}, // spUtilizationSource
Expand Down
16 changes: 7 additions & 9 deletions providers/aws/ladder/ladder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,14 +855,14 @@ func TestGetUsageBaseline_WrongScope_ReturnsError(t *testing.T) {
// it as though it were current. Now GetUsageBaseline must return an explicit error
// naming the last date when the most-recent point is older than maxSeriesAgeDays.
func TestGetUsageBaseline_StaleSeries_ReturnsError(t *testing.T) {
// Build a 30-point series whose most-recent point is maxSeriesAgeDays+1 days
// Build a 27-point series whose most-recent point is maxSeriesAgeDays+1 days
// in the past, simulating a CE feed that stopped (or a wrong date range).
// The date is built from the UTC calendar day so it is hour-independent.
staleEnd := utcDay(time.Now()).AddDate(0, 0, -(maxSeriesAgeDays + 1))
points := make([]DailyPoint, 30)
points := make([]DailyPoint, 27)
for i := range points {
points[i] = DailyPoint{
Date: staleEnd.AddDate(0, 0, -(29 - i)),
Date: staleEnd.AddDate(0, 0, -(26 - i)),
USDPerHour: float64(i + 1),
}
}
Expand Down Expand Up @@ -946,9 +946,9 @@ func TestGetUsageBaseline_AgeExactlyAtBoundary_NoError(t *testing.T) {
// built directly from the UTC calendar day (no hour arithmetic) so the test
// does not depend on the current UTC hour.
end := utcDay(time.Now()).AddDate(0, 0, -maxSeriesAgeDays)
points := make([]DailyPoint, 30)
points := make([]DailyPoint, 28)
for i := range points {
points[i] = DailyPoint{Date: end.AddDate(0, 0, -(29 - i)), USDPerHour: float64(i + 1)}
points[i] = DailyPoint{Date: end.AddDate(0, 0, -(27 - i)), USDPerHour: float64(i + 1)}
}
cov := &fakeCoverageSource{onDemandPoints: points}
a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{})
Expand All @@ -963,10 +963,8 @@ func TestGetUsageBaseline_AgeExactlyAtBoundary_NoError(t *testing.T) {
func TestGetUsageBaseline_CoverageExactlyAtBoundary_NoError(t *testing.T) {
const lookbackDays = 30
n := lookbackDays - maxMissingDays // exactly the minimum required, all in-window
cov := &fakeCoverageSource{onDemandPoints: makeRecentPoints(n)}
a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, cov, &fakeUtilizationSource{})

_, err := a.GetUsageBaseline(context.Background(), testScope(), lookbackDays, 5.0)
points := makeRecentPoints(lookbackDays)[:n]
err := validateInWindowCoverage(points, lookbackDays, utcDay(time.Now()))
require.NoError(t, err, "exactly lookbackDays-maxMissingDays in-window points must pass (boundary is inWindow < minRequired)")
}

Expand Down
Loading
Loading