Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
8ddc812
feat(aws): add shared reconcile window and fail-closed decision core
cristim Nov 6, 2026
f278ad5
fix(aws): treat a match without an ID as uncertain in reconcile Decide
cristim Nov 6, 2026
b79a324
refactor(ec2): extract productMatches from offering checks
cristim Nov 6, 2026
82933bc
feat(ec2): classify described reserved instances for purchase reconcile
cristim Nov 6, 2026
f4b96e2
fix(ec2): compare reserved instance class against the query class
cristim Nov 6, 2026
c5d186f
test(ec2): drop duplicate class-mismatch row from TestRIFit
cristim Nov 6, 2026
f7cd7a0
feat(ec2): add read-only ReconcilePurchase
cristim Nov 6, 2026
70eb84a
fix(aws): copy criteria in purchasecfg.Decide
cristim Nov 6, 2026
15eb704
refactor(aws): extract Redshift describeAllNodes paging helper
cristim Nov 6, 2026
ee28d00
style(aws): fix misspell lint in Redshift dedupe
cristim Nov 6, 2026
ee7021c
test(aws): pin mid-scan ctx cancel in findMatchingNodes
cristim Nov 6, 2026
6a956f0
feat(aws): add Redshift reserved-node reconcile classifier
cristim Nov 6, 2026
05f4e05
feat(aws): add Redshift ReconcilePurchase
cristim Nov 6, 2026
b31b9ad
test(aws): pin PurchaseReconciler wiring for EC2 and Redshift
cristim Nov 6, 2026
b4e909d
test(aws): cover every GetServiceClient slug in reconciler wiring test
cristim Nov 6, 2026
1956611
test(aws): pin EC2 empty-region refusal and the reconcile ClockSkew v…
cristim Nov 6, 2026
9b7ef42
fix(aws): reject empty node type and bad payment option in Redshift r…
cristim Nov 6, 2026
a80a35e
Merge branch 'main' into feat/298-reconcile-aws
cristim Nov 7, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

- **`common`:** `AttemptJournal`, `AttemptRecord` and an optional `PurchaseOptions.AttemptJournal`, a caller-backed durable record of purchase attempts per idempotency token (#107). Nothing reads it yet.
- **`pkg`:** `provider.PurchaseReconciler`, an optional read-only `ServiceClient` interface with `common.ReconcileRequest`, `common.ReconcileResult` and `common.ReconcileVerdict` (`ReconcileFound`/`ReconcileUnknown`, never NotFound), so a purchase that returned `ErrOutcomeUnknown` can later be checked against the provider. Types only; AWS EC2 and Redshift implementations follow (#298).
- **`aws`:** the `ec2` and `redshift` service clients now implement `provider.PurchaseReconciler`: a read-only check, after a purchase returned `ErrOutcomeUnknown`, of whether the purchase landed. The verdict is `Found` or `Unknown`, never `NotFound`. It reports `Found` only for exactly one full attribute match inside the attempt window, and `Unknown` otherwise. The other AWS clients (rds, elasticache, memorydb, opensearch, savingsplans) have an idempotency token and do not implement it. The platform consumes it in a follow-up (#298).

- **`ladder`/`aws`:** `ladder.ObservedCommitmentRateCapability` and its AWS implementation report commitment dollars per covered on-demand dollar measured on the account's existing Savings Plans and convertible RIs (account-scoped Cost Explorer utilization). Layers with no usable observation are absent.

Expand Down
77 changes: 77 additions & 0 deletions providers/aws/internal/purchasecfg/reconcile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package purchasecfg

import (
"slices"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
)

// ClockSkew pads both ends of the reconcile match window (equals the Redshift guard's skew).
const ClockSkew = 5 * time.Minute

// One fixed Reason phrase per Unknown branch; callers never invent their own.
const (
ReasonInvalidWindow = "invalid-window"
ReasonInvalidRequest = "invalid-request"
ReasonLookupFailed = "lookup-failed"
ReasonLookupIncomplete = "lookup-incomplete"
ReasonOfferingUnresolved = "offering-unresolved"
ReasonNoCandidate = "no-candidate"
ReasonAmbiguous = "ambiguous"
ReasonUncertainCandidate = "uncertain-candidate"
)

// ReconcileWindow returns the padded interval a candidate's start time must fall in.
// ok is false for a zero time or an end before start.
func ReconcileWindow(start, end time.Time) (lo, hi time.Time, ok bool) {
if start.IsZero() || end.IsZero() || end.Before(start) {
return time.Time{}, time.Time{}, false
}
return start.Add(-ClockSkew), end.Add(ClockSkew), true
}

// InWindow reports whether t lies in [lo, hi], both ends inclusive.
func InWindow(t, lo, hi time.Time) bool {
return !t.Before(lo) && !t.After(hi)
}

// UnknownResult is the verdict for early exits and failures.
func UnknownResult(reason string) common.ReconcileResult {
return common.ReconcileResult{Verdict: common.ReconcileUnknown, Reason: reason}
}

// Decide is the single fail-closed rule for both services: Found only for exactly one match and no
// uncertain candidate.
func Decide(matches, uncertain, criteria []string, tagged bool) common.ReconcileResult {
// A match without an ID cannot be reported as Found, so it counts as uncertain.
var ids []string
blank := false
for _, id := range append(append([]string{}, matches...), uncertain...) {
if id == "" {
blank = true
continue
}
ids = append(ids, id)
}
if blank || len(uncertain) > 0 {
res := UnknownResult(ReasonUncertainCandidate)
res.CandidateIDs = ids
return res
}
switch len(matches) {
case 0:
return UnknownResult(ReasonNoCandidate)
case 1:
return common.ReconcileResult{
Verdict: common.ReconcileFound,
CommitmentID: matches[0],
Criteria: slices.Clone(criteria),
Tagged: tagged,
}
default:
res := UnknownResult(ReasonAmbiguous)
res.CandidateIDs = append([]string{}, matches...)
return res
}
}
154 changes: 154 additions & 0 deletions providers/aws/internal/purchasecfg/reconcile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package purchasecfg

import (
"testing"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestReconcileWindow(t *testing.T) {
t.Parallel()
start := time.Date(2026, 11, 6, 12, 0, 0, 0, time.UTC)
end := start.Add(time.Minute)
assert.Equal(t, 5*time.Minute, ClockSkew, "window padding is part of the reconcile contract")
wantLo, wantHi := start.Add(-ClockSkew), end.Add(ClockSkew)

tests := []struct {
name string
start, end time.Time
ok bool
}{
{"valid", start, end, true},
{"equal start and end", start, start, true},
{"zero start", time.Time{}, end, false},
{"zero end", start, time.Time{}, false},
{"both zero", time.Time{}, time.Time{}, false},
{"end before start", end, start, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
lo, hi, ok := ReconcileWindow(tt.start, tt.end)
assert.Equal(t, tt.ok, ok)
if !tt.ok {
assert.True(t, lo.IsZero() && hi.IsZero())
}
})
}

lo, hi, ok := ReconcileWindow(start, end)
require.True(t, ok)
assert.Equal(t, wantLo, lo)
assert.Equal(t, wantHi, hi)
}

func TestInWindow(t *testing.T) {
t.Parallel()
start := time.Date(2026, 11, 6, 12, 0, 0, 0, time.UTC)
end := start.Add(time.Minute)
lo, hi, ok := ReconcileWindow(start, end)
require.True(t, ok)

tests := []struct {
name string
at time.Time
want bool
}{
{"exactly lo", lo, true},
{"one ns before lo", lo.Add(-time.Nanosecond), false},
{"inside", start, true},
{"exactly hi", hi, true},
{"one ns after hi", hi.Add(time.Nanosecond), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, InWindow(tt.at, lo, hi))
})
}
}

func TestDecide(t *testing.T) {
t.Parallel()
crit := []string{"instance-type", "count"}

tests := []struct {
name string
matches []string
uncertain []string
tagged bool
verdict common.ReconcileVerdict
id, reason string
candidates []string
}{
{"0 match 0 uncertain", nil, nil, false, common.ReconcileUnknown, "", ReasonNoCandidate, nil},
{"0 match 1 uncertain", nil, []string{"u1"}, false, common.ReconcileUnknown, "", ReasonUncertainCandidate, []string{"u1"}},
{"1 match 0 uncertain", []string{"m1"}, nil, false, common.ReconcileFound, "m1", "", nil},
{"1 match 0 uncertain tagged", []string{"m1"}, nil, true, common.ReconcileFound, "m1", "", nil},
{"1 match 1 uncertain", []string{"m1"}, []string{"u1"}, false, common.ReconcileUnknown, "", ReasonUncertainCandidate, []string{"m1", "u1"}},
{"1 empty-id match", []string{""}, nil, false, common.ReconcileUnknown, "", ReasonUncertainCandidate, nil},
{"2 match one empty id", []string{"m1", ""}, nil, false, common.ReconcileUnknown, "", ReasonUncertainCandidate, []string{"m1"}},
{"2 match 0 uncertain", []string{"m1", "m2"}, nil, false, common.ReconcileUnknown, "", ReasonAmbiguous, []string{"m1", "m2"}},
{"2 match 1 uncertain", []string{"m1", "m2"}, []string{"u1"}, false, common.ReconcileUnknown, "", ReasonUncertainCandidate, []string{"m1", "m2", "u1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := Decide(tt.matches, tt.uncertain, crit, tt.tagged)
assert.Equal(t, tt.verdict, got.Verdict)
assert.Equal(t, tt.id, got.CommitmentID)
assert.Equal(t, tt.reason, got.Reason)
assert.Equal(t, tt.candidates, got.CandidateIDs)
if tt.verdict == common.ReconcileFound {
assert.True(t, got.IsFound())
assert.Equal(t, crit, got.Criteria)
assert.Equal(t, tt.tagged, got.Tagged)
assert.Empty(t, got.Reason)
} else {
assert.False(t, got.IsFound())
assert.Empty(t, got.CommitmentID)
assert.Empty(t, got.Criteria)
assert.False(t, got.Tagged)
}
})
}
}

func TestUnknownResult(t *testing.T) {
t.Parallel()
got := UnknownResult(ReasonLookupFailed)
assert.Equal(t, common.ReconcileUnknown, got.Verdict)
assert.Equal(t, ReasonLookupFailed, got.Reason)
assert.Empty(t, got.CommitmentID)
assert.False(t, got.IsFound())
}

func TestReasonPhrasesAreDistinct(t *testing.T) {
t.Parallel()
phrases := []string{
ReasonInvalidWindow, ReasonInvalidRequest, ReasonLookupFailed, ReasonLookupIncomplete,
ReasonOfferingUnresolved, ReasonNoCandidate, ReasonAmbiguous, ReasonUncertainCandidate,
}
seen := map[string]bool{}
for _, p := range phrases {
assert.NotEmpty(t, p)
assert.False(t, seen[p], "duplicate reason phrase %q", p)
seen[p] = true
}
assert.Len(t, seen, 8)
}

func TestDecideCopiesCriteria(t *testing.T) {
t.Parallel()
crit := []string{"instance-type", "count"}
r1 := Decide([]string{"m1"}, nil, crit, false)
r2 := Decide([]string{"m1"}, nil, crit, false)

r1.Criteria[0] = "mutated"

assert.Equal(t, []string{"instance-type", "count"}, r2.Criteria)
assert.Equal(t, []string{"instance-type", "count"}, crit)
}
74 changes: 74 additions & 0 deletions providers/aws/reconciler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package aws

import (
"context"
"testing"

"github.com/aws/aws-sdk-go-v2/aws"
sptypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/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/provider"
)

// Only the EC2 and Redshift purchase APIs lack an idempotency token. A silent Found from any
// other client would be wrong, and a wrapper hiding the reconciler would degrade to Unknown.
func TestServiceClients_PurchaseReconcilerWiring(t *testing.T) {
t.Parallel()
cfg := aws.Config{Region: "us-east-1"}

constructors := map[string]struct {
client provider.ServiceClient
want bool
}{
"ec2": {NewEC2Client(cfg), true},
"redshift": {NewRedshiftClient(cfg), true},
"rds": {NewRDSClient(cfg), false},
"elasticache": {NewElastiCacheClient(cfg), false},
"memorydb": {NewMemoryDBClient(cfg), false},
"opensearch": {NewOpenSearchClient(cfg), false},
"savingsplans": {NewSavingsPlansClient(cfg, sptypes.SavingsPlanTypeCompute), false},
}
for name, tc := range constructors {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, ok := tc.client.(provider.PurchaseReconciler)
assert.Equal(t, tc.want, ok)
})
}
}

func TestProvider_GetServiceClient_PurchaseReconciler(t *testing.T) {
t.Parallel()
p := &Provider{cfg: aws.Config{Region: "us-east-1"}}

cases := map[common.ServiceType]bool{
common.ServiceEC2: true,
common.ServiceCompute: true,
common.ServiceRedshift: true,
common.ServiceDataWarehouse: true,
common.ServiceRDS: false,
common.ServiceElastiCache: false,
common.ServiceMemoryDB: false,
common.ServiceOpenSearch: false,
common.ServiceSavingsPlansCompute: false,
common.ServiceRelationalDB: false,
common.ServiceCache: false,
common.ServiceSearch: false,
common.ServiceSavingsPlansEC2Instance: false,
common.ServiceSavingsPlansSageMaker: false,
common.ServiceSavingsPlansDatabase: false,
common.ServiceSavingsPlansAll: false,
}
for service, want := range cases {
t.Run(string(service), func(t *testing.T) {
t.Parallel()
client, err := p.GetServiceClient(context.Background(), service, "us-east-1")
require.NoError(t, err)
_, ok := client.(provider.PurchaseReconciler)
assert.Equal(t, want, ok)
})
}
}
7 changes: 6 additions & 1 deletion providers/aws/services/ec2/offering_match.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ var offeringChecks = []struct {
if got == "" || want == "" {
return false
}
return strings.TrimSuffix(got, vpcProductSuffix) != strings.TrimSuffix(want, vpcProductSuffix)
return !productMatches(got, want)
}},
}

// productMatches compares product descriptions ignoring the " (Amazon VPC)" suffix.
func productMatches(got, want string) bool {
return strings.TrimSuffix(got, vpcProductSuffix) == strings.TrimSuffix(want, vpcProductSuffix)
}

// offeringMismatch returns the reason o does not satisfy w, or "" on a match.
func offeringMismatch(o types.ReservedInstancesOffering, w offeringWant) string {
for _, c := range offeringChecks {
Expand Down
Loading
Loading