Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8953702
feat(db): add resolution columns to ri_exchange_direct_executions
cristim Nov 7, 2026
b8a3a1e
fix(ri-exchange): release resolved direct rows from the source guard
cristim Nov 7, 2026
74fbd41
feat(config): list unresolved AWS direct RI-exchange rows
cristim Nov 7, 2026
0c0c6e5
test(config): pin NULL-region listing and early filter validation
cristim Nov 8, 2026
d5aafe3
feat(config): read one AWS direct RI-exchange row and its account pairs
cristim Nov 7, 2026
3c02b06
feat(config): resolve stuck AWS direct RI-exchange rows
cristim Nov 7, 2026
da3220f
test(config): pin the resolve boundaries
cristim Nov 8, 2026
09e7cde
fix(config): log a provider outcome that contradicts an operator reso…
cristim Nov 8, 2026
96c0132
refactor(api): share the RI-exchange constraint check
cristim Nov 8, 2026
e08eb03
feat(api): shape the direct-exchange list items and query
cristim Nov 8, 2026
a4bc744
feat(api): list unresolved AWS direct exchanges
cristim Nov 8, 2026
1c3148d
test(api): fail the direct-exchange list closed on store and auth errors
cristim Nov 8, 2026
f949141
feat(api): route and document the direct-exchange list
cristim Nov 8, 2026
ce3cbff
feat(api): resolve stuck AWS direct RI exchanges by row
cristim Nov 8, 2026
9aebc46
test(api): pin the direct-row resolve body validation
cristim Nov 8, 2026
13a49b6
feat(api): route and document the direct-row resolve
cristim Nov 8, 2026
ad11f8d
test(api): pin the direct-row resolve authorization
cristim Nov 8, 2026
85a3b4e
test(api): resolve a stuck direct exchange end to end
cristim Nov 8, 2026
26166f0
docs(runbooks): make the API the primary path for stuck direct exchanges
cristim Nov 8, 2026
ad1be75
test(api): pin the resolve response status enum to the store statuses
cristim Nov 9, 2026
b5b3d6e
style(api): fix the golangci-lint findings in the direct-exchange lis…
cristim Nov 9, 2026
a4a0e64
style(api): map the list store error like the resolve handler
cristim Nov 9, 2026
4dc8876
fix(api): keep sub-second checked_at in the resolve note and show the…
cristim Nov 9, 2026
c66dc2a
docs(runbooks): keep checked_at and reject empty fields in the break-…
cristim Nov 9, 2026
60a362e
fix(config): keep exchanges working when the app runs ahead of migrat…
cristim Nov 9, 2026
f96bd5e
fix(api): answer 503 from the direct-exchange list before migration 163
cristim Nov 9, 2026
dc2aabd
test(config): pin the guard's schema-version read failure and fix the…
cristim Nov 9, 2026
dd4e4c3
docs: record that direct finalize reads the schema version outside it…
cristim Nov 9, 2026
cb3fa5b
fix(api): admit a direct row by its AWS account number and key replay…
cristim Nov 9, 2026
e75c742
test(config): pin the guard's own error when the schema version canno…
cristim Nov 9, 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
132 changes: 132 additions & 0 deletions internal/api/handler_ri_exchange_direct_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package api

import (
"context"
"fmt"
"time"

"github.com/LeanerCloud/cloud-commitments-go/pkg/common"
"github.com/LeanerCloud/cloud-commitments-platform/internal/auth"
"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/aws/aws-lambda-go/events"
)

func (h *Handler) listRIExchangeDirectExecutions(ctx context.Context, req *events.LambdaFunctionURLRequest) (any, error) {
session, err := h.requirePermission(ctx, req, "execute", "ri-exchange")
if err != nil {
return nil, err
}
filter, err := parseRIExchangeDirectListQuery(req.QueryStringParameters)
if err != nil {
return nil, err
}
store, ok := h.config.(config.RIExchangeDirectListStore)
if !ok {
return nil, NewClientError(503, "RI exchange resolve storage is unavailable")
}
accounts, err := h.riExchangeDirectAccounts(ctx)
if err != nil {
return nil, err
}
allowed, err := h.visibleRIExchangeDirectPairs(ctx, session, store, accounts)
if mapped := mapRIExchangeResolveError(err); mapped != nil {
return nil, mapped
}
filter.Allowed = allowed
page, err := store.ListUnresolvedRIExchangeDirectExecutions(ctx, filter)
if mapped := mapRIExchangeResolveError(err); mapped != nil {
return nil, mapped
}
now := time.Now().UTC()
resp := riExchangeDirectListResponse{Executions: make([]riExchangeDirectListItem, 0, len(page.Rows))}
for i := range page.Rows {
resp.Executions = append(resp.Executions, newRIExchangeDirectListItem(&page.Rows[i], now))
}
if page.Next != nil {
resp.NextCursor = encodeRIExchangeDirectCursor(page.Next)
}
return resp, nil
}

// visibleRIExchangeDirectPairs keeps the (account, region) pairs of unresolved
// rows that the session may see: its account scope allows the account (a row
// with no registered account needs an unrestricted scope) and the grant on
// execute:ri-exchange covers the same account, provider, service and region
// the direct execute checks. The grant is evaluated by the authorization
// service, never reimplemented here, so OR'd grants, a user API key's own
// narrower permissions and region spelling behave as they do on execute.
func (h *Handler) visibleRIExchangeDirectPairs(ctx context.Context, session *Session, store config.RIExchangeDirectListStore, accounts map[string]config.CloudAccount) ([]config.RIExchangeDirectAccountRegion, error) {
scope, err := h.getAccountScope(ctx, session)
if err != nil {
return nil, fmt.Errorf("failed to get allowed accounts: %w", err)
}
pairs, err := store.ListUnresolvedRIExchangeDirectAccountRegions(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list unresolved direct exchange accounts: %w", err)
}
var visible []config.RIExchangeDirectAccountRegion
for _, p := range pairs {
account, inScope := riExchangeDirectPairAccount(scope, accounts, p)
if !inScope {
continue
}
granted, err := h.riExchangeDirectPairGranted(ctx, session, account, p.Region)
if err != nil {
return nil, err
}
if granted {
visible = append(visible, p)
}
}
return visible, nil
}

// riExchangeDirectAccounts loads the registered AWS accounts by ID, so the scope
// check can also match an account's AWS account number.
func (h *Handler) riExchangeDirectAccounts(ctx context.Context) (map[string]config.CloudAccount, error) {
provider := string(common.ProviderAWS)
accounts, err := h.config.ListCloudAccounts(ctx, config.CloudAccountFilter{Provider: &provider})
if err != nil {
return nil, fmt.Errorf("failed to list cloud accounts: %w", err)
}
byID := make(map[string]config.CloudAccount, len(accounts))
for i := range accounts {
byID[accounts[i].ID] = accounts[i]
}
return byID, nil
}

// riExchangeDirectPairAccount returns the account the grant is checked
// against (the unattributed sentinel for a row with no registered account) and
// whether the session's account scope covers it. A row with no account needs
// an unrestricted scope. A group may list an account by its ID or by its AWS
// account number (the group write keeps an external id verbatim), so either
// form admits the row; external ids are unique per provider, so the number
// cannot name another AWS account.
func riExchangeDirectPairAccount(scope auth.AccountScope, accounts map[string]config.CloudAccount, p config.RIExchangeDirectAccountRegion) (string, bool) {
if p.CloudAccountID == nil {
return unattributedAccountConstraint, scope.AllowsAll()
}
id := *p.CloudAccountID
if scope.Allows(id, "") {
return id, true
}
external := accounts[id].ExternalID
return id, external != "" && scope.Allows(external, "")
}

// riExchangeDirectPairGranted asks the authorization service whether the
// grant covers an AWS EC2 exchange in the account and region. A denial is
// false; any other failure is an error.
func (h *Handler) riExchangeDirectPairGranted(ctx context.Context, session *Session, account, region string) (bool, error) {
err := h.requirePermissionConstraints(ctx, session, "execute", "ri-exchange", []auth.PermissionConstraints{{
AccountIDs: []string{account},
Providers: []string{string(common.ProviderAWS)},
Services: []string{string(common.ServiceEC2)},
Regions: []string{region},
}})
if ce, isClient := IsClientError(err); isClient && ce.code == 403 {
return false, nil
}
return err == nil, err
}
102 changes: 102 additions & 0 deletions internal/api/handler_ri_exchange_direct_list_failclosed_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package api

import (
"context"
"errors"
"fmt"
"testing"

"github.com/aws/aws-lambda-go/events"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
)

type failingDirectListStore struct {
*directListStore
pairsErr, listErr error
}

func (s *failingDirectListStore) ListUnresolvedRIExchangeDirectAccountRegions(ctx context.Context) ([]config.RIExchangeDirectAccountRegion, error) {
if s.pairsErr != nil {
return nil, s.pairsErr
}
return s.directListStore.ListUnresolvedRIExchangeDirectAccountRegions(ctx)
}

func (s *failingDirectListStore) ListUnresolvedRIExchangeDirectExecutions(ctx context.Context, f config.RIExchangeDirectListFilter) (*config.RIExchangeDirectListPage, error) {
if s.listErr != nil {
return nil, s.listErr
}
return s.directListStore.ListUnresolvedRIExchangeDirectExecutions(ctx, f)
}

func callDirectList(t *testing.T, store *failingDirectListStore, authErr error) (any, error) {
t.Helper()
mockAuth := new(MockAuthService)
mockAuth.On("ValidateSession", mock.Anything, "tok").Return(&Session{UserID: apgUserID}, nil).Maybe()
mockAuth.On("HasPermissionAPI", mock.Anything, mock.Anything, "execute", "ri-exchange").Return(true, nil).Maybe()
mockAuth.On("GetAllowedAccountsAPI", mock.Anything, mock.Anything).Return([]string(nil), nil).Maybe()
if authErr != nil {
mockAuth.On("HasPermissionForConstraintsAPI", mock.Anything, mock.Anything, "execute", "ri-exchange", mock.Anything).Return(false, authErr)
} else {
mockAuth.On("HasPermissionForConstraintsAPI", mock.Anything, mock.Anything, "execute", "ri-exchange", mock.Anything).Return(true, nil).Maybe()
}
h := &Handler{config: store, auth: mockAuth}
req := &events.LambdaFunctionURLRequest{Headers: map[string]string{"Authorization": "Bearer tok"}}
return h.listRIExchangeDirectExecutions(context.Background(), req)
}

// A failure anywhere in the visibility chain is a server error with no
// partial list: nothing is shown on a guess.
func TestListRIExchangeDirectExecutions_FailsClosed(t *testing.T) {
rows := []config.RIExchangeDirectRow{
dlRow(dlID1, dlPtr(dlAcctA), "eu-west-1", "unknown", 1),
dlRow(dlID2, dlPtr(dlAcctB), "eu-west-1", "unknown", 2),
}
boom := errors.New("backend down")
call := callDirectList
tests := []struct {
name string
store *failingDirectListStore
auth error
}{
{"the pair query fails", &failingDirectListStore{directListStore: dlStore(rows...), pairsErr: boom}, nil},
{"the page query fails", &failingDirectListStore{directListStore: dlStore(rows...), listErr: boom}, nil},
{"the constraint check fails with a non-403 error", &failingDirectListStore{directListStore: dlStore(rows...)}, boom},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
out, err := call(t, tc.store, tc.auth)
require.Error(t, err)
_, isClient := IsClientError(err)
assert.False(t, isClient, "a backend failure is a 5xx, not a refusal: %v", err)
assert.Nil(t, out, "no partial list")
})
}
t.Run("control: the same setup lists both rows", func(t *testing.T) {
out, err := call(t, &failingDirectListStore{directListStore: dlStore(rows...)}, nil)
require.NoError(t, err)
assert.Equal(t, []string{dlID1, dlID2}, dlIDs(t, out))
})
}

// Before migration 163 the list answers 503, whichever of its two store reads
// notices first; it is not a generic 500.
func TestListRIExchangeDirectExecutions_NotReadyIs503(t *testing.T) {
rows := []config.RIExchangeDirectRow{dlRow(dlID1, dlPtr(dlAcctA), "eu-west-1", "unknown", 1)}
notReady := fmt.Errorf("list unresolved direct exchange rows: %w", config.ErrRIExchangeDirectResolveNotReady)
for name, store := range map[string]*failingDirectListStore{
"the pair query": {directListStore: dlStore(rows...), pairsErr: notReady},
"the page query": {directListStore: dlStore(rows...), listErr: notReady},
} {
t.Run(name, func(t *testing.T) {
out, err := callDirectList(t, store, nil)
require.Error(t, err)
assert.Equal(t, 503, clientCode(t, err))
assert.Nil(t, out)
})
}
}
152 changes: 152 additions & 0 deletions internal/api/handler_ri_exchange_direct_list_item_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package api

import (
"encoding/json"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/LeanerCloud/cloud-commitments-platform/internal/config"
"github.com/LeanerCloud/cloud-commitments-platform/internal/purchase"
)

// Unit tests for the pieces of GET /api/ri-exchange/direct-executions that need
// no store (#1836): what a stored row exposes, the resolvable hint, query
// validation and the cursor.

const (
dlAcctA = scopedInAccount
dlAcctB = scopedOutAccount
dlID1 = "00000000-0000-4000-8000-000000000001"
dlID2 = "00000000-0000-4000-8000-000000000002"
dlID3 = "00000000-0000-4000-8000-000000000003"
dlID4 = "00000000-0000-4000-8000-000000000004"
)

var dlBase = time.Now().UTC().Truncate(time.Microsecond).Add(-time.Hour)

func dlPtr(s string) *string { return &s }

func dlRow(id string, account *string, region, status string, minutes int) config.RIExchangeDirectRow {
at := dlBase.Add(time.Duration(minutes) * time.Minute)
r := config.RIExchangeDirectRow{
ID: id, Status: status, CloudAccountID: account, Region: region,
SourceIDs: []string{"ri-" + id}, Targets: json.RawMessage(`[{"offering_id":"o","count":1}]`),
MaxPaymentDue: "100", MaxPaymentCurrency: "USD", ExecutedBy: "alice@example.com", CreatedAt: at,
}
if status != "submitted" {
r.CompletedAt = &at
}
return r
}

func keysOf[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}

// [fail-first] The response carries exactly the allowlisted keys: never the raw
// error, the executing user id or the API key id.
func TestRIExchangeDirectListItem_KeysAreAllowlisted(t *testing.T) {
row := dlRow(dlID1, dlPtr(dlAcctA), "eu-west-1", "unknown", 1)
row.Error = "InvalidParameterValue for account 123456789012 from 10.1.2.3: raw provider text"

raw, err := json.Marshal(riExchangeDirectListResponse{
Executions: []riExchangeDirectListItem{newRIExchangeDirectListItem(&row, time.Now())}, NextCursor: ""})
require.NoError(t, err)

var top map[string]json.RawMessage
require.NoError(t, json.Unmarshal(raw, &top))
assert.ElementsMatch(t, []string{"executions", "next_cursor"}, keysOf(top))
var items []map[string]any
require.NoError(t, json.Unmarshal(top["executions"], &items))
require.Len(t, items, 1)
assert.ElementsMatch(t, []string{"id", "status", "provider", "region", "cloud_account_id", "source_ids", "targets", "max_payment_due",
"max_payment_currency", "created_at", "executed_by", "error_summary", "resolvable", "resolvable_at"}, keysOf(items[0]))

assert.Equal(t, purchase.SanitizeStoredError(row.Error), items[0]["error_summary"])
for _, leak := range []string{"123456789012", "10.1.2.3", "raw provider text"} {
assert.NotContains(t, string(raw), leak)
}
assert.Equal(t, "aws", items[0]["provider"])
}

func TestRIExchangeDirectListItem_CloudAccountID(t *testing.T) {
attributed := dlRow(dlID1, dlPtr(dlAcctA), "eu-west-1", "unknown", 1)
unattributed := dlRow(dlID2, nil, "eu-west-1", "unknown", 1)

raw, err := json.Marshal(newRIExchangeDirectListItem(&attributed, time.Now()))
require.NoError(t, err)
assert.Contains(t, string(raw), `"cloud_account_id":"`+dlAcctA+`"`)

raw, err = json.Marshal(newRIExchangeDirectListItem(&unattributed, time.Now()))
require.NoError(t, err)
assert.Contains(t, string(raw), `"cloud_account_id":null`)
}

func TestRIExchangeDirectListItem_ResolvableHint(t *testing.T) {
now := time.Now().UTC()
at := func(r config.RIExchangeDirectRow, created time.Time) config.RIExchangeDirectRow {
r.CreatedAt = created
if r.CompletedAt != nil {
r.CompletedAt = &created
}
return r
}
sub := at(dlRow(dlID1, nil, "eu-west-1", "submitted", 0), now.Add(-5*time.Minute))
oldSub := at(dlRow(dlID2, nil, "eu-west-1", "submitted", 0), now.Add(-config.RIExchangeDirectResolveSubmittedAfter-time.Minute))
unk := at(dlRow(dlID3, nil, "eu-west-1", "unknown", 0), now.Add(-2*time.Minute))
freshUnk := at(dlRow(dlID4, nil, "eu-west-1", "unknown", 0), now.Add(-10*time.Second))

got := func(r config.RIExchangeDirectRow) riExchangeDirectListItem {
return newRIExchangeDirectListItem(&r, now)
}
assert.False(t, got(sub).Resolvable)
assert.True(t, got(sub).ResolvableAt.Equal(sub.CreatedAt.Add(config.RIExchangeDirectResolveSubmittedAfter)))
assert.True(t, got(oldSub).Resolvable)
assert.True(t, got(unk).Resolvable)
assert.True(t, got(unk).ResolvableAt.Equal(unk.CompletedAt.Add(config.RIExchangeDirectResolveUnknownCheckAfter)))
assert.False(t, got(freshUnk).Resolvable)
}

func TestParseRIExchangeDirectListQuery(t *testing.T) {
for name, q := range map[string]map[string]string{
"bad status": {"status": "completed"},
"limit zero": {"limit": "0"},
"limit too big": {"limit": "201"},
"limit not int": {"limit": "ten"},
"bad region": {"region": "EU West"},
"bad cursor": {"cursor": "!!!"},
"cursor no id": {"cursor": "MjAyNi0wMS0wMVQwMDowMDowMFp8bm90LWEtdXVpZA"},
"ri_id control": {"ri_id": "ri\n1"},
"ri_id too long": {"ri_id": strings.Repeat("a", riExchangeDirectRIIDMax+1)},
} {
_, err := parseRIExchangeDirectListQuery(q)
assert.Equal(t, 400, clientCode(t, err), name)
}

f, err := parseRIExchangeDirectListQuery(map[string]string{"status": "unknown", "region": "eu-west-1", "ri_id": "RI-X", "limit": "7"})
require.NoError(t, err)
assert.Equal(t, []string{"unknown"}, f.Statuses)
assert.Equal(t, "eu-west-1", f.Region)
assert.Equal(t, "RI-X", f.RIID)
assert.Equal(t, 7, f.Limit)

f, err = parseRIExchangeDirectListQuery(nil)
require.NoError(t, err)
assert.Equal(t, config.RIExchangeDirectListDefaultLimit, f.Limit)
}

func TestRIExchangeDirectCursor_RoundTripKeepsMicroseconds(t *testing.T) {
at := time.Date(2026, 11, 7, 10, 20, 30, 123456000, time.UTC)
got, err := decodeRIExchangeDirectCursor(encodeRIExchangeDirectCursor(&config.RIExchangeDirectCursor{CreatedAt: at, ID: dlID2}))
require.NoError(t, err)
assert.True(t, got.CreatedAt.Equal(at))
assert.Equal(t, dlID2, got.ID)
}
Loading
Loading