From b6d69a08839d93bf6b6850464203a2c1b47d6a6d Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 13 Oct 2026 11:13:21 +0200 Subject: [PATCH] feat(api): require execute:purchases to arm ladder execution (#118 PR2) Gate the two arming writers for the ladder executor, which nothing runs yet: PUT /api/config turning ladder_execution_enabled off->on, and PUT /api/ladder-config arming a config (enabled + auto_approve) or raising or clearing max_hourly_commit_per_run. Both need a real user holding execute:purchases; the admin API key and admin:* alone are refused. Disarming, lowering the cap and plain laddering_enabled stay ordinary updates. Stamp who/when/via for the execution switch (migration 000116, nullable, round-tripped by the whole-config upsert). Cap is validated as exact micro-dollars for NUMERIC(20,6): more than 6 decimals, below 0.000001 or above 1e12 is a 400. Structured log on every authorized escalation and switch change. Refs #118 Co-Authored-By: Claude Sonnet 5.5 --- internal/api/handler_config.go | 15 +- internal/api/handler_ladder.go | 4 + internal/api/handler_ladder_test.go | 9 +- internal/api/ladder_arming_grant.go | 126 +++++++++++ internal/api/ladder_arming_grant_test.go | 205 ++++++++++++++++++ internal/config/ladder_cap.go | 34 +++ internal/config/store_postgres.go | 17 +- .../config/store_postgres_coverage_test.go | 3 + ...postgres_ladder_arming_integration_test.go | 92 ++++++++ .../config/store_postgres_pgxmock_test.go | 18 +- internal/config/types.go | 6 + internal/config/validation.go | 4 +- internal/config/validation_test.go | 13 ++ ...16_ladder_execution_switch_stamps.down.sql | 4 + ...0116_ladder_execution_switch_stamps.up.sql | 8 + 15 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 internal/api/ladder_arming_grant.go create mode 100644 internal/api/ladder_arming_grant_test.go create mode 100644 internal/config/ladder_cap.go create mode 100644 internal/config/store_postgres_ladder_arming_integration_test.go create mode 100644 internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.down.sql create mode 100644 internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.up.sql diff --git a/internal/api/handler_config.go b/internal/api/handler_config.go index fc3aa5c3..74dcdb06 100644 --- a/internal/api/handler_config.go +++ b/internal/api/handler_config.go @@ -94,7 +94,7 @@ func (h *Handler) updateConfig(ctx context.Context, req *events.LambdaFunctionUR // ClientError(400) on a bad body or validation failure, which the store // propagates unchanged; DB/transport errors surface as 500. A changed // default reaches the service configs inside the same transaction. - var switchBefore bool + var switchBefore, ladderBefore bool merged, err := h.config.UpdateGlobalConfigAtomic(ctx, func(existing *config.GlobalConfig) error { // Snapshot before the wholesale unmarshal: this body writes onto the // entire GlobalConfig, so ri_exchange_mode / ri_exchange_enabled reach @@ -104,6 +104,7 @@ func (h *Handler) updateConfig(ctx context.Context, req *events.LambdaFunctionUR // write lands on. before := riExchangeArmStateOf(existing) switchBefore = existing.AutoPurchaseEnabled + ladderBefore = existing.LadderExecutionEnabled // grace_period_days is a map: json.Unmarshal into a non-nil map MERGES // keys (an omitted key can never be deleted). When the caller sends the @@ -121,16 +122,26 @@ func (h *Handler) updateConfig(ctx context.Context, req *events.LambdaFunctionUR if gErr := h.requireRIExchangeAutoModeGrant(ctx, req, before, riExchangeArmStateOf(existing)); gErr != nil { return gErr } - return h.requireAutoPurchaseSwitchGrant(ctx, req, session, switchBefore, existing) + return h.requireSwitchGrants(ctx, req, session, switchBefore, ladderBefore, existing) }) if err != nil { return nil, err } logAutoPurchaseSwitchChange(ctx, session, switchBefore, merged) + logLadderExecutionSwitchChange(ctx, session, ladderBefore, merged) return &StatusResponse{Status: "updated"}, nil } +// requireSwitchGrants chains the arming gates of the two money-spending +// switches: auto-purchase, then ladder execution. +func (h *Handler) requireSwitchGrants(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, autoBefore, ladderBefore bool, cfg *config.GlobalConfig) error { + if err := h.requireAutoPurchaseSwitchGrant(ctx, req, session, autoBefore, cfg); err != nil { + return err + } + return h.requireLadderExecutionSwitchGrant(ctx, req, session, ladderBefore, cfg) +} + func (h *Handler) requireGlobalConfigScope(ctx context.Context, session *Session) error { scope, err := h.getAccountScope(ctx, session) if err != nil { diff --git a/internal/api/handler_ladder.go b/internal/api/handler_ladder.go index ac8aa7a6..20247759 100644 --- a/internal/api/handler_ladder.go +++ b/internal/api/handler_ladder.go @@ -128,6 +128,10 @@ func (h *Handler) upsertLadderConfig(ctx context.Context, req *events.LambdaFunc return nil, err } + if err := h.gateLadderConfigWrite(ctx, req, session, &cfg); err != nil { + return nil, err + } + result, err := h.config.UpsertLadderConfig(ctx, &cfg) if err != nil { return nil, fmt.Errorf("failed to upsert ladder config: %w", err) diff --git a/internal/api/handler_ladder_test.go b/internal/api/handler_ladder_test.go index 4fd57c2e..cbecfd6b 100644 --- a/internal/api/handler_ladder_test.go +++ b/internal/api/handler_ladder_test.go @@ -37,6 +37,7 @@ func newLadderHandler(t *testing.T) (*Handler, *MockConfigStore, **config.Ladder // .Maybe() so the 400-path test (store never reached) doesn't trip // AssertExpectations on an unmet expectation. The success tests prove the // store was reached by asserting *captured is non-nil (set only in Run). + mockStore.On("GetLadderConfig", ctx, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil).Maybe() mockStore.On("UpsertLadderConfig", ctx, mock.AnythingOfType("*config.LadderConfigDB")). Run(func(args mock.Arguments) { captured = args.Get(1).(*config.LadderConfigDB) @@ -213,10 +214,10 @@ func ladderScopedReq(body string) *events.LambdaFunctionURLRequest { } // ladderScopedBody builds a valid ladder config body targeting accountID. -// mode=auto_approve with max_hourly_commit_per_run omitted (nil => no cap) is -// the shape from the issue's failure scenario. +// The config is disabled so these scope tests stay independent of the arming +// grant (covered in ladder_arming_grant_test.go). func ladderScopedBody(accountID string) string { - return `{"cloud_account_id":"` + accountID + `","provider":"aws","enabled":true,` + + return `{"cloud_account_id":"` + accountID + `","provider":"aws","enabled":false,` + `"mode":"auto_approve","cadence":"daily",` + ladderValidRamp + `}` } @@ -230,6 +231,7 @@ func ladderScopedHandler(t *testing.T) (*Handler, *MockConfigStore) { Return(&config.CloudAccount{ID: scopedInAccount, Name: "in-scope", Provider: "aws"}, nil).Maybe() mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount). Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil).Maybe() + mockStore.On("GetLadderConfig", mock.Anything, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil).Maybe() return h, mockStore } @@ -272,6 +274,7 @@ func TestUpsertLadderConfig_UnrestrictedSessionUnaffected(t *testing.T) { handler, mockStore := scopedHandler(t) // no accounts => grantAdmin => unrestricted mockStore.On("GetCloudAccount", mock.Anything, scopedOutAccount). Return(&config.CloudAccount{ID: scopedOutAccount, Name: "victim-account", Provider: "aws"}, nil) + mockStore.On("GetLadderConfig", mock.Anything, mock.Anything, mock.Anything).Return((*config.LadderConfigDB)(nil), nil) mockStore.On("UpsertLadderConfig", mock.Anything, mock.AnythingOfType("*config.LadderConfigDB")). Return(&config.LadderConfigDB{ID: "written-row", CloudAccountID: scopedOutAccount}, nil) diff --git a/internal/api/ladder_arming_grant.go b/internal/api/ladder_arming_grant.go new file mode 100644 index 00000000..a3010b93 --- /dev/null +++ b/internal/api/ladder_arming_grant.go @@ -0,0 +1,126 @@ +package api + +import ( + "context" + "fmt" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/ladder" + "github.com/LeanerCloud/cloud-commitments-go/pkg/logging" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/aws/aws-lambda-go/events" +) + +// Ladder execution arming is a money-spending authorization, gated like the +// auto-purchase switch (auto_purchase_grant.go): execute:purchases for a real +// user, never the admin API key. Plain LadderingEnabled (planning only) and +// every de-escalation (switch off, disarm, lower the cap) stay ordinary +// update:config writes. + +// requireLadderExecutionSwitchGrant gates and stamps a change of +// GlobalConfig.LadderExecutionEnabled inside the UpdateGlobalConfigAtomic +// closure. Only off->on needs the grant; any change is stamped. +func (h *Handler) requireLadderExecutionSwitchGrant(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, before bool, cfg *config.GlobalConfig) error { + if before == cfg.LadderExecutionEnabled { + return nil + } + if cfg.LadderExecutionEnabled { + if err := h.requireAutoPurchaseGrant(ctx, req, session); err != nil { + return err + } + } + by, via, at := session.UserID, credentialKind(ctx, session), time.Now().UTC() + cfg.LadderExecutionChangedBy, cfg.LadderExecutionChangedVia, cfg.LadderExecutionChangedAt = &by, &via, &at + return nil +} + +// logLadderExecutionSwitchChange records a committed change of the switch. +func logLadderExecutionSwitchChange(ctx context.Context, session *Session, before bool, after *config.GlobalConfig) { + if after == nil || after.LadderExecutionEnabled == before { + return + } + logging.Infof("ladder execution switch changed: actor=%s via=%s %t -> %t", session.UserID, credentialKind(ctx, session), before, after.LadderExecutionEnabled) +} + +// ladderConfigArmed reports whether the config, with the global switches on, +// would buy without an approver: enabled and in auto-approve mode. +func ladderConfigArmed(c *config.LadderConfigDB) bool { + return c != nil && c.Enabled && c.Mode == string(ladder.ModeAutoApprove) +} + +// ladderCapRaised reports whether after allows more spend than before. A nil +// cap means no cap, so clearing a cap is a raise. Caps reaching the gate have +// passed Validate; an unrepresentable one fails closed as the maximum. +func ladderCapRaised(before, after *float64) bool { + if after == nil { + return before != nil + } + if before == nil { + return false + } + return ladderCapMicros(after) > ladderCapMicros(before) +} + +func ladderCapMicros(v *float64) int64 { + n, err := config.LadderCapMicros(*v) + if err != nil { + return 1<<63 - 1 + } + return n +} + +// ladderConfigArmEscalates reports whether the write takes the config from +// not-armed to armed, or raises or clears the cap of an armed config. before +// is nil when no row exists. +func ladderConfigArmEscalates(before, after *config.LadderConfigDB) bool { + if !ladderConfigArmed(after) { + return false + } + if !ladderConfigArmed(before) { + return true + } + return ladderCapRaised(before.MaxHourlyCommitPerRun, after.MaxHourlyCommitPerRun) +} + +// requireLadderConfigArmGrant gates a ladder_config write and logs the +// authorized escalation. before is nil when no row exists. +func (h *Handler) requireLadderConfigArmGrant(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, before, after *config.LadderConfigDB) error { + if !ladderConfigArmEscalates(before, after) { + return nil + } + if err := h.requireAutoPurchaseGrant(ctx, req, session); err != nil { + return err + } + var oldCap *float64 + if before != nil { + oldCap = before.MaxHourlyCommitPerRun + } + logging.Infof("ladder config arm or cap raise authorized (write follows): account=%s provider=%s config=%q actor=%s via=%s armed %t -> true cap %s -> %s", + after.CloudAccountID, after.Provider, ladderConfigID(before), session.UserID, credentialKind(ctx, session), + ladderConfigArmed(before), formatLadderCap(oldCap), formatLadderCap(after.MaxHourlyCommitPerRun)) + return nil +} + +// gateLadderConfigWrite loads the stored row and applies the arming gate. A +// lookup failure refuses the write (fail closed). +func (h *Handler) gateLadderConfigWrite(ctx context.Context, req *events.LambdaFunctionURLRequest, session *Session, after *config.LadderConfigDB) error { + before, err := h.config.GetLadderConfig(ctx, after.CloudAccountID, after.Provider) + if err != nil { + return fmt.Errorf("failed to load ladder config: %w", err) + } + return h.requireLadderConfigArmGrant(ctx, req, session, before, after) +} + +func ladderConfigID(c *config.LadderConfigDB) string { + if c == nil { + return "" + } + return c.ID +} + +func formatLadderCap(v *float64) string { + if v == nil { + return "NULL" + } + return fmt.Sprintf("%.6f", *v) +} diff --git a/internal/api/ladder_arming_grant_test.go b/internal/api/ladder_arming_grant_test.go new file mode 100644 index 00000000..24b43101 --- /dev/null +++ b/internal/api/ladder_arming_grant_test.go @@ -0,0 +1,205 @@ +package api + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +// Coverage for the ladder arming gate (platform#118 PR2): turning +// LadderExecutionEnabled on, or arming / raising a ladder_config, needs +// execute:purchases for a real user. Admin without the verb and the admin API +// key are refused; de-escalations stay ordinary. Reuses the apg* helpers of +// auto_purchase_grant_test.go. + +const lgAcct = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + +func lgStored(enabled bool, mode string, capUSD *float64) *config.LadderConfigDB { + return &config.LadderConfigDB{ID: "cfg-1", CloudAccountID: lgAcct, Provider: "aws", Enabled: enabled, Mode: mode, MaxHourlyCommitPerRun: capUSD} +} + +func lgBody(enabled bool, mode, capJSON string) string { + capField := "" + if capJSON != "" { + capField = `"max_hourly_commit_per_run":` + capJSON + `,` + } + return fmt.Sprintf(`{"cloud_account_id":"%s","provider":"aws","enabled":%t,"mode":"%s","cadence":"daily",%s%s}`, + lgAcct, enabled, mode, capField, ladderValidRamp) +} + +func lgCall(caller apgCaller, stored *config.LadderConfigDB, body string) (*MockConfigStore, error) { + store := new(MockConfigStore) + store.On("GetLadderConfig", mock.Anything, lgAcct, "aws").Return(stored, nil) + store.On("UpsertLadderConfig", mock.Anything, mock.Anything).Return(&config.LadderConfigDB{ID: "cfg-1"}, nil).Maybe() + store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, Provider: "aws"}, nil + } + h, req := caller.handlerAndReq(store, body) + _, err := h.upsertLadderConfig(context.Background(), req) + return store, err +} + +func TestLadderArmingGate_ConfigWrites(t *testing.T) { + admin := apgCaller{perms: apgAdminOnly} + granted := apgCaller{perms: apgAdminPlus} + key := apgCaller{key: true} + auto, email := "auto_approve", "email_approval" + cap1, cap03 := apgCap(1), apgCap(0.3) + + tests := []struct { + name string + caller apgCaller + stored *config.LadderConfigDB + body string + wantRefused bool + }{ + {"create armed: admin refused", admin, nil, lgBody(true, auto, "1"), true}, + {"create armed: admin API key refused", key, nil, lgBody(true, auto, "1"), true}, + {"create armed: execute:purchases allowed", granted, nil, lgBody(true, auto, "1"), false}, + {"create disabled auto: admin allowed", admin, nil, lgBody(false, auto, "1"), false}, + {"create enabled email_approval: admin allowed", admin, nil, lgBody(true, email, ""), false}, + {"enable a disabled auto config: admin refused", admin, lgStored(false, auto, cap1), lgBody(true, auto, "1"), true}, + {"enable a disabled auto config: admin API key refused", key, lgStored(false, auto, cap1), lgBody(true, auto, "1"), true}, + {"email_approval to auto: admin refused", admin, lgStored(true, email, cap1), lgBody(true, auto, "1"), true}, + {"email_approval to auto: execute:purchases allowed", granted, lgStored(true, email, cap1), lgBody(true, auto, "1"), false}, + {"raise cap: admin refused", admin, lgStored(true, auto, cap1), lgBody(true, auto, "2"), true}, + {"raise cap: admin API key refused", key, lgStored(true, auto, cap1), lgBody(true, auto, "2"), true}, + {"raise cap: execute:purchases allowed", granted, lgStored(true, auto, cap1), lgBody(true, auto, "2"), false}, + {"raise by one micro: admin refused", admin, lgStored(true, auto, cap1), lgBody(true, auto, "1.000001"), true}, + {"clear cap: admin refused", admin, lgStored(true, auto, cap1), lgBody(true, auto, ""), true}, + {"clear cap: admin API key refused", key, lgStored(true, auto, cap1), lgBody(true, auto, ""), true}, + {"same cap: admin allowed", admin, lgStored(true, auto, cap1), lgBody(true, auto, "1"), false}, + {"same cap 0.3 (binary-float trap): admin allowed", admin, lgStored(true, auto, cap03), lgBody(true, auto, "0.30"), false}, + {"lower cap: admin API key allowed", key, lgStored(true, auto, cap1), lgBody(true, auto, "0.5"), false}, + {"set a cap on an uncapped armed config: admin API key allowed", key, lgStored(true, auto, nil), lgBody(true, auto, "5"), false}, + {"uncapped stays uncapped: admin allowed", admin, lgStored(true, auto, nil), lgBody(true, auto, ""), false}, + {"disarm (disable): admin API key allowed", key, lgStored(true, auto, cap1), lgBody(false, auto, "1"), false}, + {"switch to email_approval: admin API key allowed", key, lgStored(true, auto, cap1), lgBody(true, email, "1"), false}, + {"raise cap while disabled: admin allowed", admin, lgStored(false, auto, cap1), lgBody(false, auto, "9"), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + store, err := lgCall(tc.caller, tc.stored, tc.body) + if tc.wantRefused { + apgRefused(t, err) + assert.False(t, apgCalled(store, "UpsertLadderConfig"), "a refused write must not reach the store") + return + } + require.NoError(t, err) + assert.True(t, apgCalled(store, "UpsertLadderConfig")) + }) + } +} + +func TestLadderArmingGate_ConfigLookupErrorFailsClosed(t *testing.T) { + store := new(MockConfigStore) + store.On("GetLadderConfig", mock.Anything, lgAcct, "aws").Return((*config.LadderConfigDB)(nil), fmt.Errorf("db down")) + store.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { + return &config.CloudAccount{ID: id, Provider: "aws"}, nil + } + h, req := apgCaller{perms: apgAdminPlus}.handlerAndReq(store, lgBody(true, "auto_approve", "1")) + _, err := h.upsertLadderConfig(context.Background(), req) + require.Error(t, err) + assert.False(t, apgCalled(store, "UpsertLadderConfig")) +} + +func TestLadderArmingGate_CapValidation(t *testing.T) { + for _, capJSON := range []string{"0", "-1", "0.0000004", "1.1234567", "1e13", "1e308", `"5"`} { + t.Run(capJSON, func(t *testing.T) { + store, err := lgCall(apgCaller{perms: apgAdminPlus}, nil, lgBody(false, "auto_approve", capJSON)) + require.Error(t, err) + ce, ok := IsClientError(err) + require.True(t, ok, "%v", err) + assert.Equal(t, 400, ce.code) + assert.False(t, apgCalled(store, "UpsertLadderConfig")) + }) + } + // Boundary values stay valid. + for _, capJSON := range []string{"0.000001", "123.456789", "1e12"} { + _, err := lgCall(apgCaller{perms: apgAdminPlus}, nil, lgBody(false, "auto_approve", capJSON)) + require.NoError(t, err, capJSON) + } +} + +func TestLadderArmingGate_ExecutionSwitch(t *testing.T) { + off, on := baseValidConfig(), baseValidConfig() + on.LadderExecutionEnabled = true + const flip = `{"ladder_execution_enabled":true}` + + t.Run("off to on: admin without grant refused", func(t *testing.T) { + saved, err := apgConfigCall(t, apgCaller{perms: apgAdminOnly}, off, flip) + apgRefused(t, err) + assert.Nil(t, saved) + }) + t.Run("off to on: admin API key refused", func(t *testing.T) { + saved, err := apgConfigCall(t, apgCaller{key: true}, off, flip) + apgRefused(t, err) + assert.Nil(t, saved) + }) + t.Run("off to on: execute:purchases allowed and stamped", func(t *testing.T) { + before := time.Now().UTC().Add(-time.Second) + saved, err := apgConfigCall(t, apgCaller{perms: apgAdminPlus}, off, flip) + require.NoError(t, err) + require.NotNil(t, saved) + assert.True(t, saved.LadderExecutionEnabled) + require.NotNil(t, saved.LadderExecutionChangedBy) + assert.Equal(t, apgUserID, *saved.LadderExecutionChangedBy) + require.NotNil(t, saved.LadderExecutionChangedVia) + assert.Equal(t, string(PrincipalSession), *saved.LadderExecutionChangedVia) + require.NotNil(t, saved.LadderExecutionChangedAt) + assert.True(t, saved.LadderExecutionChangedAt.After(before)) + assert.Nil(t, saved.AutoPurchaseChangedBy, "the auto-purchase stamp is untouched") + }) + t.Run("on to off: admin API key allowed and stamped", func(t *testing.T) { + saved, err := apgConfigCall(t, apgCaller{key: true}, on, `{"ladder_execution_enabled":false}`) + require.NoError(t, err) + require.NotNil(t, saved) + assert.False(t, saved.LadderExecutionEnabled) + require.NotNil(t, saved.LadderExecutionChangedVia) + assert.Equal(t, string(PrincipalAdminAPIKey), *saved.LadderExecutionChangedVia) + }) + t.Run("unchanged switch: admin may save other settings, no stamp", func(t *testing.T) { + saved, err := apgConfigCall(t, apgCaller{perms: apgAdminOnly}, on, `{"ladder_execution_enabled":true,"default_coverage":70}`) + require.NoError(t, err) + require.NotNil(t, saved) + assert.Nil(t, saved.LadderExecutionChangedBy) + }) + t.Run("laddering_enabled (planning only) needs no grant", func(t *testing.T) { + saved, err := apgConfigCall(t, apgCaller{key: true}, off, `{"laddering_enabled":true}`) + require.NoError(t, err) + require.NotNil(t, saved) + assert.True(t, saved.LadderingEnabled) + assert.Nil(t, saved.LadderExecutionChangedBy) + }) + t.Run("both switches flipped without grant: refused", func(t *testing.T) { + saved, err := apgConfigCall(t, apgCaller{perms: apgAdminOnly}, off, `{"ladder_execution_enabled":true,"auto_purchase_enabled":false}`) + apgRefused(t, err) + assert.Nil(t, saved) + }) +} + +func TestLadderArmingGate_StampsCannotBeForgedViaConfigBody(t *testing.T) { + stored := baseValidConfig() + by, via := "real-actor", "user" + at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + stored.LadderExecutionChangedBy, stored.LadderExecutionChangedVia, stored.LadderExecutionChangedAt = &by, &via, &at + + for _, body := range []string{ + `{"ladder_execution_changed_by":"mallory","ladder_execution_changed_via":"x","ladder_execution_changed_at":"2030-01-01T00:00:00Z"}`, + `{"LadderExecutionChangedBy":"mallory","LadderExecutionChangedVia":"x","LadderExecutionChangedAt":"2030-01-01T00:00:00Z"}`, + } { + saved, err := apgConfigCall(t, apgCaller{perms: apgAdminOnly}, stored, body) + require.NoError(t, err, body) + require.NotNil(t, saved, body) + require.NotNil(t, saved.LadderExecutionChangedBy, body) + assert.Equal(t, "real-actor", *saved.LadderExecutionChangedBy, body) + assert.True(t, saved.LadderExecutionChangedAt.Equal(at), body) + } +} diff --git a/internal/config/ladder_cap.go b/internal/config/ladder_cap.go new file mode 100644 index 00000000..c2cf1c07 --- /dev/null +++ b/internal/config/ladder_cap.go @@ -0,0 +1,34 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +// MinLadderHourlyCommit and MaxLadderHourlyCommitLimit bound +// ladder_configs.max_hourly_commit_per_run. Below one micro-dollar the +// NUMERIC(20,6) column rounds to 0; the upper bound keeps the micro-dollar +// value inside int64 and far above any real hourly commitment. +const ( + MinLadderHourlyCommit = 0.000001 + MaxLadderHourlyCommitLimit = 1e12 +) + +// LadderCapMicros returns the per-run hourly cap in exact micro-dollars. Caps +// with more than 6 decimals are rejected instead of rounded, so the stored and +// the compared value are identical (same approach as StepCapCents). +func LadderCapMicros(v float64) (int64, error) { + if !(v >= MinLadderHourlyCommit) || !(v <= MaxLadderHourlyCommitLimit) { + return 0, fmt.Errorf("max_hourly_commit_per_run must be between %v and %v, got: %v", MinLadderHourlyCommit, MaxLadderHourlyCommitLimit, v) + } + whole, frac, _ := strings.Cut(strconv.FormatFloat(v, 'f', -1, 64), ".") + if len(frac) > 6 { + return 0, fmt.Errorf("max_hourly_commit_per_run must have at most 6 decimal places, got: %v", v) + } + n, err := strconv.ParseInt(whole+(frac + "000000")[:6], 10, 64) + if err != nil { + return 0, fmt.Errorf("max_hourly_commit_per_run is not representable: %w", err) + } + return n, nil +} diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index 5eb49e51..b9af2734 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -94,7 +94,8 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo offering_class, require_different_approver, auto_purchase_enabled, - auto_purchase_changed_by, auto_purchase_changed_at, auto_purchase_changed_via + auto_purchase_changed_by, auto_purchase_changed_at, auto_purchase_changed_via, + ladder_execution_changed_by, ladder_execution_changed_at, ladder_execution_changed_via FROM global_config WHERE id = 1 ` @@ -132,6 +133,9 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo &config.AutoPurchaseChangedBy, &config.AutoPurchaseChangedAt, &config.AutoPurchaseChangedVia, + &config.LadderExecutionChangedBy, + &config.LadderExecutionChangedAt, + &config.LadderExecutionChangedVia, ) if err != nil { @@ -266,8 +270,9 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G recommendations_cache_stale_hours, recommendations_lookback_days, purchase_delay_hours, laddering_enabled, ladder_execution_enabled, offering_class, require_different_approver, auto_purchase_enabled, - auto_purchase_changed_by, auto_purchase_changed_at, auto_purchase_changed_via - ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28) + auto_purchase_changed_by, auto_purchase_changed_at, auto_purchase_changed_via, + ladder_execution_changed_by, ladder_execution_changed_at, ladder_execution_changed_via + ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31) ON CONFLICT (id) DO UPDATE SET enabled_providers = $1, notification_email = $2, @@ -297,6 +302,9 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G auto_purchase_changed_by = $26, auto_purchase_changed_at = $27, auto_purchase_changed_via = $28, + ladder_execution_changed_by = $29, + ladder_execution_changed_at = $30, + ladder_execution_changed_via = $31, updated_at = NOW() ` @@ -369,6 +377,9 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G config.AutoPurchaseChangedBy, config.AutoPurchaseChangedAt, config.AutoPurchaseChangedVia, + config.LadderExecutionChangedBy, + config.LadderExecutionChangedAt, + config.LadderExecutionChangedVia, ) if err != nil { diff --git a/internal/config/store_postgres_coverage_test.go b/internal/config/store_postgres_coverage_test.go index 23303f85..5218aa20 100644 --- a/internal/config/store_postgres_coverage_test.go +++ b/internal/config/store_postgres_coverage_test.go @@ -605,6 +605,9 @@ func TestSaveGlobalConfig_OfferingClassBindsAt23(t *testing.T) { pgxmock.AnyArg(), // $26 auto_purchase_changed_by pgxmock.AnyArg(), // $27 auto_purchase_changed_at pgxmock.AnyArg(), // $28 auto_purchase_changed_via + pgxmock.AnyArg(), // $29 ladder_execution_changed_by + pgxmock.AnyArg(), // $30 ladder_execution_changed_at + pgxmock.AnyArg(), // $31 ladder_execution_changed_via ). WillReturnResult(pgxmock.NewResult("INSERT", 1)) diff --git a/internal/config/store_postgres_ladder_arming_integration_test.go b/internal/config/store_postgres_ladder_arming_integration_test.go new file mode 100644 index 00000000..a4b8b160 --- /dev/null +++ b/internal/config/store_postgres_ladder_arming_integration_test.go @@ -0,0 +1,92 @@ +//go:build integration +// +build integration + +package config + +import ( + "context" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/database/postgres/migrations" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPostgresStoreDB_LadderArming runs against a dedicated database named pr2_* +// (LADDER_ARMING_TEST_DATABASE_URL): a fresh migrate leaves both ladder switches +// OFF with NULL stamps, the stamps survive a whole-config write from another +// path, and every cap LadderCapMicros accepts is stored as the same micro-dollars +// it compared (NUMERIC(20,6)). +func TestPostgresStoreDB_LadderArming(t *testing.T) { + dsn := os.Getenv("LADDER_ARMING_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set LADDER_ARMING_TEST_DATABASE_URL to a dedicated pr2_* test database") + } + parsed, err := url.Parse(dsn) + require.NoError(t, err) + require.True(t, strings.HasPrefix(strings.TrimPrefix(parsed.Path, "/"), "pr2_"), "database must be dedicated to PR2 verification") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + pool, err := pgxpool.New(ctx, dsn) + require.NoError(t, err) + defer pool.Close() + require.NoError(t, migrations.RunMigrations(ctx, pool, getTestMigrationsPath(), "", "")) + store := &PostgresStore{db: pool} + + cfg, err := store.GetGlobalConfig(ctx) + require.NoError(t, err) + assert.False(t, cfg.LadderingEnabled, "a fresh migrate leaves planning OFF") + assert.False(t, cfg.LadderExecutionEnabled, "a fresh migrate leaves execution OFF") + assert.Nil(t, cfg.LadderExecutionChangedBy) + assert.Nil(t, cfg.LadderExecutionChangedAt) + assert.Nil(t, cfg.LadderExecutionChangedVia) + + at := time.Date(2026, 10, 13, 1, 2, 3, 0, time.UTC) + by, via := "user-1", "user" + _, err = store.UpdateGlobalConfigAtomic(ctx, func(existing *GlobalConfig) error { + existing.LadderExecutionEnabled = true + existing.LadderExecutionChangedBy, existing.LadderExecutionChangedVia, existing.LadderExecutionChangedAt = &by, &via, &at + return nil + }) + require.NoError(t, err) + + // The RI-exchange style whole-config write must not wipe the stamps. + _, err = store.UpdateGlobalConfigAtomic(ctx, func(existing *GlobalConfig) error { + existing.RIExchangeEnabled = true + existing.RIExchangeMode = "manual" + return nil + }) + require.NoError(t, err) + cfg, err = store.GetGlobalConfig(ctx) + require.NoError(t, err) + assert.True(t, cfg.LadderExecutionEnabled) + require.NotNil(t, cfg.LadderExecutionChangedBy) + assert.Equal(t, "user-1", *cfg.LadderExecutionChangedBy) + require.NotNil(t, cfg.LadderExecutionChangedVia) + assert.Equal(t, "user", *cfg.LadderExecutionChangedVia) + require.NotNil(t, cfg.LadderExecutionChangedAt) + assert.True(t, cfg.LadderExecutionChangedAt.Equal(at)) + + account := &CloudAccount{Name: "pr2 fixture", Provider: "aws", ExternalID: "111111111111", Enabled: true} + require.NoError(t, store.CreateCloudAccount(ctx, account)) + base := LadderConfigDB{CloudAccountID: account.ID, Provider: "aws", Mode: "auto_approve", Cadence: "daily", TargetCoverage: 80, BufferFraction: 0.1, BaselinePercentile: 5, LookbackDays: 30, BufferUtilizationThreshold: 50, MaxActionsPerRun: 5, RampSchedule: []byte(`{"steps":[{"after_days":0,"fraction":1}]}`)} + caps := []float64{0.000001, 0.1, 0.3, 0.123456, 1.000001, 123.456789, 999999999999.999999, 1e12} + for _, v := range caps { + sent, err := LadderCapMicros(v) + require.NoError(t, err, "cap %v", v) + c := base + v := v + c.MaxHourlyCommitPerRun = &v + _, err = store.UpsertLadderConfig(ctx, &c) + require.NoError(t, err, "cap %v", v) + var micros int64 + require.NoError(t, pool.QueryRow(ctx, `SELECT (max_hourly_commit_per_run * 1000000)::bigint FROM ladder_configs WHERE cloud_account_id = $1`, account.ID).Scan(µs)) + require.Equal(t, sent, micros, "stored micros must equal compared micros for cap %v", v) + } +} diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index 5cdf4ff1..d886dcbf 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -66,6 +66,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) { "require_different_approver", "auto_purchase_enabled", "auto_purchase_changed_by", "auto_purchase_changed_at", "auto_purchase_changed_via", + "ladder_execution_changed_by", "ladder_execution_changed_at", "ladder_execution_changed_via", } rows := pgxmock.NewRows(cols).AddRow( []string{"aws"}, strPtr("ops@example.com"), true, @@ -82,6 +83,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) { false, false, nil, nil, nil, + nil, nil, nil, ) mock.ExpectQuery("SELECT").WillReturnRows(rows) @@ -130,6 +132,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) { "require_different_approver", "auto_purchase_enabled", "auto_purchase_changed_by", "auto_purchase_changed_at", "auto_purchase_changed_via", + "ladder_execution_changed_by", "ladder_execution_changed_at", "ladder_execution_changed_via", } baseRow := func(graceJSON string) []any { return []any{ @@ -147,6 +150,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) { false, false, nil, nil, nil, + nil, nil, nil, } } @@ -209,6 +213,7 @@ var globalConfigCols = []string{ "require_different_approver", "auto_purchase_enabled", "auto_purchase_changed_by", "auto_purchase_changed_at", "auto_purchase_changed_via", + "ladder_execution_changed_by", "ladder_execution_changed_at", "ladder_execution_changed_via", } // TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite proves the F2 @@ -242,6 +247,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) { false, // require_different_approver false, // auto_purchase_enabled nil, nil, nil, // auto_purchase_changed_by/at/via + nil, nil, nil, // ladder_execution_changed_by/at/via ) // Strict order: the SELECT and the UPSERT must sit between the same @@ -250,7 +256,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) { mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("SELECT", 1)) mock.ExpectQuery("FROM global_config").WillReturnRows(seeded) - mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(28)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(31)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) mock.ExpectCommit() @@ -301,6 +307,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_ApplyErrorRollsBack(t *testing.T) { false, false, nil, nil, nil, + nil, nil, nil, ) mock.ExpectBegin() @@ -339,6 +346,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi false, false, nil, nil, nil, + nil, nil, nil, ) } setCoverage := func(existing *GlobalConfig) error { @@ -353,7 +361,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("SELECT", 1)) mock.ExpectQuery("FROM global_config").WillReturnRows(seeded()) - mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(28)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(31)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) mock.ExpectExec("UPDATE service_configs"). WithArgs(false, 1, false, "no-upfront", true, 70.0, false, RampImmediate). @@ -372,7 +380,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PropagatesOnlyChangedDefaults(t *testi mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). WillReturnResult(pgxmock.NewResult("SELECT", 1)) mock.ExpectQuery("FROM global_config").WillReturnRows(seeded()) - mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(28)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(31)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) dbErr := errors.New("service_configs update failed") mock.ExpectExec("UPDATE service_configs").WithArgs(anyArgsCfg(8)...).WillReturnError(dbErr) @@ -3299,9 +3307,11 @@ func TestPGXMock_UpdateGlobalConfigAtomic_PreservesAutoPurchaseStamps(t *testing false, true, &by, &at, &via, + &by, &at, &via, // ladder_execution_changed_by/at/via ) - args := anyArgsCfg(28) + args := anyArgsCfg(31) args[25], args[26], args[27] = &by, &at, &via + args[28], args[29], args[30] = &by, &at, &via mock.ExpectBegin() mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()). diff --git a/internal/config/types.go b/internal/config/types.go index 65dca4c9..56a83547 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -93,6 +93,12 @@ type GlobalConfig struct { AutoPurchaseChangedAt *time.Time `json:"-" db:"auto_purchase_changed_at"` AutoPurchaseChangedVia *string `json:"-" db:"auto_purchase_changed_via"` + // LadderExecutionChanged{By,At,Via} stamp the last change of + // LadderExecutionEnabled (migration 000116); same json:"-" reasoning. + LadderExecutionChangedBy *string `json:"-" db:"ladder_execution_changed_by"` + LadderExecutionChangedAt *time.Time `json:"-" db:"ladder_execution_changed_at"` + LadderExecutionChangedVia *string `json:"-" db:"ladder_execution_changed_via"` + // OfferingClass controls the EC2 Reserved Instance offering class used // during purchase. Accepted values: "convertible" (default) and // "standard". Convertible RIs can be exchanged for a different diff --git a/internal/config/validation.go b/internal/config/validation.go index 8f65fa1d..8080dfbc 100644 --- a/internal/config/validation.go +++ b/internal/config/validation.go @@ -797,8 +797,8 @@ func (c *LadderConfigDB) validateLadderCoverageBounds() error { // buffer utilization threshold. func (c *LadderConfigDB) validateLadderRunLimits() error { if v := c.MaxHourlyCommitPerRun; v != nil { - if math.IsNaN(*v) || math.IsInf(*v, 0) || !(*v > 0) { - return fmt.Errorf("max_hourly_commit_per_run %g must be a positive finite number when set (omit for no cap)", *v) + if _, err := LadderCapMicros(*v); err != nil { + return err } } if c.MaxActionsPerRun <= 0 { diff --git a/internal/config/validation_test.go b/internal/config/validation_test.go index 118d5c71..96b3d39f 100644 --- a/internal/config/validation_test.go +++ b/internal/config/validation_test.go @@ -1,6 +1,7 @@ package config import ( + "math" "testing" "github.com/stretchr/testify/assert" @@ -1096,3 +1097,15 @@ func TestIsValidRampScheduleType(t *testing.T) { func stringPtr(s string) *string { return &s } + +func TestLadderCapMicros(t *testing.T) { + for in, want := range map[float64]int64{0.000001: 1, 0.3: 300000, 1: 1000000, 123.456789: 123456789, 1e12: 1e18} { + got, err := LadderCapMicros(in) + assert.NoError(t, err, in) + assert.Equal(t, want, got, in) + } + for _, in := range []float64{0, -1, 0.0000004, 1.1234567, 1e13, 1e308, math.NaN(), math.Inf(1)} { + _, err := LadderCapMicros(in) + assert.Error(t, err, in) + } +} diff --git a/internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.down.sql b/internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.down.sql new file mode 100644 index 00000000..7cdf25aa --- /dev/null +++ b/internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.down.sql @@ -0,0 +1,4 @@ +ALTER TABLE global_config + DROP COLUMN IF EXISTS ladder_execution_changed_by, + DROP COLUMN IF EXISTS ladder_execution_changed_at, + DROP COLUMN IF EXISTS ladder_execution_changed_via; diff --git a/internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.up.sql b/internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.up.sql new file mode 100644 index 00000000..7f974d5c --- /dev/null +++ b/internal/database/postgres/migrations/000116_ladder_execution_switch_stamps.up.sql @@ -0,0 +1,8 @@ +-- Migration 000116 (platform#118 PR2): who last changed the ladder execution +-- switch (global_config.ladder_execution_enabled), when, and with what kind of +-- credential. Nullable, additive, no backfill: NULL means "never changed since +-- the column existed". Idempotent. +ALTER TABLE global_config + ADD COLUMN IF NOT EXISTS ladder_execution_changed_by TEXT, + ADD COLUMN IF NOT EXISTS ladder_execution_changed_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS ladder_execution_changed_via TEXT;