From f511312bf99bf9b416dcaee25240c02db52217fc Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 16 Jul 2026 21:56:11 +0300 Subject: [PATCH] feat(ladder): wire write side behind ladder_execution_enabled kill-switch Add the ladder_execution_enabled kill-switch (default FALSE) and wire the AWS ladder write side behind it. Both laddering_enabled AND ladder_execution_enabled must be true for any purchase or reshape to reach AWS; otherwise the wired ladder returns ErrLadderExecutionDisabled without an outbound call. The ladder_run handler stays plan-only. Migration 000083 adds the boolean column (numbered 000083, not 000082, to avoid colliding with the in-flight 000082_rename_cancelled_to_canceled on PR #1277). The column is threaded through GlobalConfig via COALESCE so older rows read as false; pgxmock tests cover the new column 22. providers/aws/ladder: WireWriteSide wires real EC2 + Savings Plans (umbrella mode) clients plus the exchangeRunner; WireWriteSideDisabled wires shims that return the exported ErrLadderExecutionDisabled sentinel (errors.Is-able, distinct from the internal errWriteNotWired programming-error). internal/server: exchangeRunnerAdapter satisfies the unexported exchangeRunner seam via Go structural typing, forwarding LadderRunID and DryRun into RunAutoExchangeParams (gap G10 / issue #1348 origin scoping). wireLadderWriteSide is a no-op for non-AWSLadder test fakes. executionEnabled is threaded structurally through handleLadderRun -> runLadderConfigs -> processOneLadderConfig -> buildAndWireCapability -> wireLadderWriteSide; buildAndWireCapability keeps processOneLadderConfig at or below cyclomatic 10. Tests: a real *AWSLadder wired with executionEnabled=false refuses both PurchaseLayer and ReshapeBuffer with ErrLadderExecutionDisabled and no AWS call (offline, no creds); ladder-package kill-switch tests assert the same at the provider boundary; handler plan-only invariant t.Fatal guards retained. --- internal/config/store_postgres.go | 10 +- .../config/store_postgres_pgxmock_test.go | 9 +- internal/config/types.go | 9 ++ .../000083_ladder_execution_enabled.down.sql | 2 + .../000083_ladder_execution_enabled.up.sql | 12 ++ internal/server/handler_ladder.go | 16 +- internal/server/handler_ladder_test.go | 6 +- internal/server/ladder_write.go | 143 ++++++++++++++++++ internal/server/ladder_write_test.go | 90 +++++++++++ providers/aws/ladder/factory.go | 45 ++++++ providers/aws/ladder/ladder.go | 6 + providers/aws/ladder/purchase_test.go | 45 ++++++ 12 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 internal/database/postgres/migrations/000083_ladder_execution_enabled.down.sql create mode 100644 internal/database/postgres/migrations/000083_ladder_execution_enabled.up.sql create mode 100644 internal/server/ladder_write.go create mode 100644 internal/server/ladder_write_test.go diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index d6094381b..5b51c129d 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -80,7 +80,8 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo grace_period_days, recommendations_cache_stale_hours, recommendations_lookback_days, COALESCE(purchase_delay_hours, 0), - COALESCE(laddering_enabled, false) + COALESCE(laddering_enabled, false), + COALESCE(ladder_execution_enabled, false) FROM global_config WHERE id = 1 ` @@ -111,6 +112,7 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo &config.RecommendationsLookbackDays, &config.PurchaseDelayHours, &config.LadderingEnabled, + &config.LadderExecutionEnabled, ) if err != nil { @@ -209,8 +211,8 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G auto_collect, collection_schedule, notification_days_before, grace_period_days, recommendations_cache_stale_hours, recommendations_lookback_days, - purchase_delay_hours, laddering_enabled - ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21) + purchase_delay_hours, laddering_enabled, ladder_execution_enabled + ) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) ON CONFLICT (id) DO UPDATE SET enabled_providers = $1, notification_email = $2, @@ -233,6 +235,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G recommendations_lookback_days = $19, purchase_delay_hours = $20, laddering_enabled = $21, + ladder_execution_enabled = $22, updated_at = NOW() ` @@ -290,6 +293,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G recommendationsLookbackDays, config.PurchaseDelayHours, config.LadderingEnabled, + config.LadderExecutionEnabled, ) if err != nil { diff --git a/internal/config/store_postgres_pgxmock_test.go b/internal/config/store_postgres_pgxmock_test.go index bd2869268..bd85724e5 100644 --- a/internal/config/store_postgres_pgxmock_test.go +++ b/internal/config/store_postgres_pgxmock_test.go @@ -60,6 +60,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) { "recommendations_cache_stale_hours", "recommendations_lookback_days", "purchase_delay_hours", "laddering_enabled", + "ladder_execution_enabled", } rows := pgxmock.NewRows(cols).AddRow( []string{"aws"}, strPtr("ops@example.com"), true, @@ -71,6 +72,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) { 24, 7, 0, false, + false, ) mock.ExpectQuery("SELECT").WillReturnRows(rows) @@ -112,6 +114,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) { "recommendations_cache_stale_hours", "recommendations_lookback_days", "purchase_delay_hours", "laddering_enabled", + "ladder_execution_enabled", } baseRow := func(graceJSON string) []any { return []any{ @@ -124,6 +127,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) { 24, 7, 0, false, + false, } } @@ -181,6 +185,7 @@ var globalConfigCols = []string{ "recommendations_cache_stale_hours", "recommendations_lookback_days", "purchase_delay_hours", "laddering_enabled", + "ladder_execution_enabled", } // TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite proves the F2 @@ -209,6 +214,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) { 24, 7, 48, false, // laddering_enabled = false + false, // ladder_execution_enabled = false ) // Strict order: the SELECT and the UPSERT must sit between the same @@ -217,7 +223,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(21)...). + mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(22)...). WillReturnResult(pgxmock.NewResult("INSERT", 1)) mock.ExpectCommit() @@ -263,6 +269,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_ApplyErrorRollsBack(t *testing.T) { 24, 7, 0, false, + false, ) mock.ExpectBegin() diff --git a/internal/config/types.go b/internal/config/types.go index d96a5dd00..75b13addc 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -69,6 +69,15 @@ type GlobalConfig struct { // engine runs fire regardless of per-account LadderConfig.Enabled settings. // Set to true to allow per-account configs to activate individually. LadderingEnabled bool `json:"laddering_enabled" db:"laddering_enabled"` + + // LadderExecutionEnabled gates the write side of the ladder capability + // (migration 000083). BOTH LadderingEnabled AND LadderExecutionEnabled + // must be true for PurchaseLayer / ReshapeBuffer to be wired with real + // AWS SDK clients. Default false: existing deployments that enable + // laddering produce plans but never call AWS purchase APIs until an + // operator explicitly opts in. Fail-loud: wireLadderWriteSide returns + // a typed ErrLadderExecutionDisabled when this is false. + LadderExecutionEnabled bool `json:"ladder_execution_enabled" db:"ladder_execution_enabled"` } // DefaultGracePeriodDays is the fallback window used when a provider diff --git a/internal/database/postgres/migrations/000083_ladder_execution_enabled.down.sql b/internal/database/postgres/migrations/000083_ladder_execution_enabled.down.sql new file mode 100644 index 000000000..d3233fe24 --- /dev/null +++ b/internal/database/postgres/migrations/000083_ladder_execution_enabled.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE global_config + DROP COLUMN IF EXISTS ladder_execution_enabled; diff --git a/internal/database/postgres/migrations/000083_ladder_execution_enabled.up.sql b/internal/database/postgres/migrations/000083_ladder_execution_enabled.up.sql new file mode 100644 index 000000000..6e27b80f6 --- /dev/null +++ b/internal/database/postgres/migrations/000083_ladder_execution_enabled.up.sql @@ -0,0 +1,12 @@ +-- Migration 000083: ladder_execution_enabled global kill-switch. +-- +-- Stacks on laddering_enabled (migration 000079): BOTH must be true for +-- the ladder capability write side (PurchaseLayer / ReshapeBuffer) to be +-- wired with real AWS clients. Default FALSE means existing deployments +-- that enable laddering produce plans but never call AWS purchase APIs until +-- an operator explicitly opts in (fail-loud, no silent fallback). +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS. + +ALTER TABLE global_config + ADD COLUMN IF NOT EXISTS ladder_execution_enabled BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/internal/server/handler_ladder.go b/internal/server/handler_ladder.go index f3cd38007..9b95687ce 100644 --- a/internal/server/handler_ladder.go +++ b/internal/server/handler_ladder.go @@ -123,7 +123,7 @@ func (app *Application) handleLadderRun(ctx context.Context) (*LadderRunResult, } now := time.Now().UTC() - result := app.runLadderConfigs(ctx, allConfigs, ownAccountID, region, term, paymentOpt, now) + result := app.runLadderConfigs(ctx, allConfigs, ownAccountID, region, term, paymentOpt, now, globalCfg.LadderExecutionEnabled) log.Printf("ladder_run done: planned=%d skipped_cadence=%d skipped_disabled=%d skipped_multi_account=%d errored=%d", result.Planned, result.SkippedCadence, result.SkippedDisabled, result.SkippedMultiAccount, result.Errored) @@ -192,10 +192,11 @@ func (app *Application) runLadderConfigs( term pkgladder.Term, paymentOpt pkgladder.PaymentOption, now time.Time, + executionEnabled bool, ) *LadderRunResult { result := &LadderRunResult{} for i := range configs { - result.record(app.processOneLadderConfig(ctx, &configs[i], ownAccountID, region, term, paymentOpt, now)) + result.record(app.processOneLadderConfig(ctx, &configs[i], ownAccountID, region, term, paymentOpt, now, executionEnabled)) } return result } @@ -211,6 +212,7 @@ func (app *Application) processOneLadderConfig( term pkgladder.Term, paymentOpt pkgladder.PaymentOption, now time.Time, + executionEnabled bool, ) ladderConfigOutcome { if !dbCfg.Enabled { log.Printf("ladder_run: config %s: enabled=false, skipping", dbCfg.ID) @@ -248,14 +250,10 @@ func (app *Application) processOneLadderConfig( return outcomeSkippedCadence } - // Build the LadderCapability for this account. - if app.LadderCapabilityFactory == nil { - log.Printf("ladder_run: config %s: LadderCapabilityFactory is nil (not wired), erroring", dbCfg.ID) - return outcomeErrored - } - capability, err := app.LadderCapabilityFactory(ctx, region, cloudAcct.ExternalID) + // Build and wire the LadderCapability for this account. + capability, err := app.buildAndWireCapability(ctx, region, cloudAcct.ExternalID, executionEnabled) if err != nil { - log.Printf("ladder_run: config %s: failed to build ladder capability: %v", dbCfg.ID, err) + log.Printf("ladder_run: config %s: %v", dbCfg.ID, err) return outcomeErrored } diff --git a/internal/server/handler_ladder_test.go b/internal/server/handler_ladder_test.go index da04c670b..ab29981c9 100644 --- a/internal/server/handler_ladder_test.go +++ b/internal/server/handler_ladder_test.go @@ -467,7 +467,7 @@ func TestHandleLadderRun_MultiAccountSkip_CountedAndIsolated(t *testing.T) { // Put the foreign config first to prove isolation is order-independent. configs := []config.LadderConfigDB{cfgForeign, cfgHealthy} - result := app.runLadderConfigs(ctx, configs, ownAccount, "us-east-1", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now) + result := app.runLadderConfigs(ctx, configs, ownAccount, "us-east-1", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now, false) require.NotNil(t, result) // (a) The foreign config must be counted as SkippedMultiAccount. @@ -869,7 +869,7 @@ func TestProcessOneLadderConfig_CadenceDBError_Errored(t *testing.T) { }, } - result := app.runLadderConfigs(ctx, []config.LadderConfigDB{dbCfg}, ownAccount, "us-east-1", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now) + result := app.runLadderConfigs(ctx, []config.LadderConfigDB{dbCfg}, ownAccount, "us-east-1", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now, false) assert.Equal(t, 1, result.Errored, "a cadence lookup error must count the config Errored") assert.Equal(t, 0, result.Planned) @@ -993,7 +993,7 @@ func TestHandleLadderRun_MultiConfigIsolation(t *testing.T) { // Order the broken config first to prove a leading failure does not abort // the healthy config that follows. configs := []config.LadderConfigDB{cfgBroken, cfgHealthy} - result := app.runLadderConfigs(ctx, configs, ownAccount, "us-east-1", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now) + result := app.runLadderConfigs(ctx, configs, ownAccount, "us-east-1", pkgladder.Term1Year, pkgladder.PaymentNoUpfront, now, false) require.NotNil(t, result) assert.Equal(t, 1, result.Planned, "the healthy config must still be planned despite the broken one") diff --git a/internal/server/ladder_write.go b/internal/server/ladder_write.go new file mode 100644 index 000000000..97f716238 --- /dev/null +++ b/internal/server/ladder_write.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "errors" + "fmt" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + + "github.com/LeanerCloud/CUDly/pkg/exchange" + pkgladder "github.com/LeanerCloud/CUDly/pkg/ladder" + awsprovider "github.com/LeanerCloud/CUDly/providers/aws" + awsladder "github.com/LeanerCloud/CUDly/providers/aws/ladder" + ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" +) + +// exchangeRunnerAdapter bridges internal/server wiring (exchange store, EC2 exchange +// client, offering lookup) with the exchangeRunner seam expected by AWSLadder. It +// satisfies the unexported providers/aws/ladder.exchangeRunner interface via Go +// structural typing: the concrete RunAutoExchange method signature matches the +// interface definition, so the compiler accepts this type wherever exchangeRunner +// is expected without the caller naming the interface. +// +// The adapter owns the full client construction and conversion that +// executeRIExchangeReshape performs for the standalone RI-exchange path, adapted +// for ladder runs: LadderRunID and DryRun are forwarded from the seam arguments +// directly into RunAutoExchangeParams. +type exchangeRunnerAdapter struct { + app *Application + region string + accountID string +} + +// RunAutoExchange implements providers/aws/ladder.exchangeRunner. It constructs +// fresh AWS clients, lists convertible RIs and utilization, converts them for the +// exchange package, then delegates to exchange.RunAutoExchange. +// +// ladderRunID is forwarded to RunAutoExchangeParams.LadderRunID so exchange scopes +// its pending-cancellation to the ladder origin (issue #1348 / gap G10). DryRun +// is forwarded so the exchange engine skips all mutations and returns Simulated +// outcomes when the ladder run was started in dry-run mode. +func (a *exchangeRunnerAdapter) RunAutoExchange(ctx context.Context, cfg exchange.RIExchangeConfig, ladderRunID *string, dryRun bool) (*exchange.AutoExchangeResult, error) { + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(a.region)) + if err != nil { + return nil, fmt.Errorf("exchangeRunnerAdapter: load AWS config: %w", err) + } + + ec2Client := awsprovider.NewEC2ClientDirect(awsCfg) + recsClient := awsprovider.NewRecommendationsClientDirect(awsCfg) + + instances, err := ec2Client.ListConvertibleReservedInstances(ctx) + if err != nil { + return nil, fmt.Errorf("exchangeRunnerAdapter: list convertible RIs: %w", err) + } + utilData, err := recsClient.GetRIUtilization(ctx, cfg.LookbackDays) + if err != nil { + return nil, fmt.Errorf("exchangeRunnerAdapter: get RI utilization: %w", err) + } + + riInfos, utilInfos, riMetadata := convertForAutoExchange(instances, utilData) + store := newConfigExchangeStoreAdapter(a.app.Config) + + lookupOffering := func(ctx context.Context, instanceType, productDesc, tenancy, scope string, duration int64) (string, error) { + return ec2Client.FindConvertibleOffering(ctx, ec2svc.FindConvertibleOfferingParams{ + InstanceType: instanceType, + ProductDescription: productDesc, + Tenancy: tenancy, + Scope: scope, + Duration: duration, + }) + } + + return exchange.RunAutoExchange(ctx, exchange.RunAutoExchangeParams{ + Store: store, + ExchangeClient: exchange.NewExchangeClient(awsCfg), + LookupOffering: lookupOffering, + RIs: riInfos, + Utilization: utilInfos, + Config: cfg, + AccountID: a.accountID, + Region: a.region, + DashboardURL: a.app.appConfig.DashboardURL, + RIMetadata: riMetadata, + LadderRunID: ladderRunID, + DryRun: dryRun, + }) +} + +// buildAndWireCapability constructs a LadderCapability via the factory and wires its +// write side. Extracted from processOneLadderConfig to keep that function's cyclomatic +// complexity below the project threshold (10). The returned error carries no config +// ID: the sole caller already prefixes its log line with the config ID, so repeating +// it here would duplicate it in the output. +func (app *Application) buildAndWireCapability(ctx context.Context, region, accountID string, executionEnabled bool) (pkgladder.LadderCapability, error) { + if app.LadderCapabilityFactory == nil { + return nil, errors.New("LadderCapabilityFactory is nil (not wired)") + } + capability, err := app.LadderCapabilityFactory(ctx, region, accountID) + if err != nil { + return nil, fmt.Errorf("failed to build ladder capability: %w", err) + } + return app.wireLadderWriteSide(ctx, executionEnabled, region, accountID, capability) +} + +// wireLadderWriteSide wires the write side of a LadderCapability if and only if +// cap is a *awsladder.AWSLadder. For test fakes (non-AWSLadder implementations) +// it returns cap unchanged so existing handler tests continue to work without +// modifying their fake capabilities. +// +// When executionEnabled is false, WireWriteSideDisabled is called: the ladder +// accepts PurchaseLayer / ReshapeBuffer calls but immediately returns +// ErrLadderExecutionDisabled without touching any AWS API. When executionEnabled +// is true, WireWriteSide wires real EC2 and Savings Plans clients plus the +// exchangeRunnerAdapter that forwards ladderRunID and dryRun to the exchange +// package's seam. +func (app *Application) wireLadderWriteSide( + ctx context.Context, + executionEnabled bool, + region, accountID string, + capability pkgladder.LadderCapability, +) (pkgladder.LadderCapability, error) { + l, ok := capability.(*awsladder.AWSLadder) + if !ok { + // Test fake or non-AWS capability: plan-only invariant holds without wiring. + return capability, nil + } + if !executionEnabled { + wired, err := awsladder.WireWriteSideDisabled(l) + if err != nil { + return nil, fmt.Errorf("wireLadderWriteSide: disabled: %w", err) + } + return wired, nil + } + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(region)) + if err != nil { + return nil, fmt.Errorf("wireLadderWriteSide: load AWS config: %w", err) + } + wired, err := awsladder.WireWriteSide(l, awsCfg, &exchangeRunnerAdapter{app: app, region: region, accountID: accountID}) + if err != nil { + return nil, fmt.Errorf("wireLadderWriteSide: %w", err) + } + return wired, nil +} diff --git a/internal/server/ladder_write_test.go b/internal/server/ladder_write_test.go new file mode 100644 index 000000000..2a95da27e --- /dev/null +++ b/internal/server/ladder_write_test.go @@ -0,0 +1,90 @@ +package server + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pkgcommon "github.com/LeanerCloud/CUDly/pkg/common" + pkgladder "github.com/LeanerCloud/CUDly/pkg/ladder" + awsladder "github.com/LeanerCloud/CUDly/providers/aws/ladder" +) + +// f64ptr returns a pointer to the given float64. BufferReshapeConfig uses +// *float64 caps to distinguish "unlimited" (nil) from an explicit value. +func f64ptr(v float64) *float64 { return &v } + +// TestWireLadderWriteSide_ExecutionDisabled_RealAWSLadder_RefusesWithoutAWSCall is +// the direct spend-prevention test for the ladder_execution_enabled kill-switch. +// +// Unlike the handler tests (which exercise wireLadderWriteSide's no-op branch with +// a non-AWSLadder fake), this constructs a REAL *awsladder.AWSLadder via the +// production factory and wires it with executionEnabled=false, then drives the two +// money-path methods with fully valid inputs (so boundary validation passes and the +// disabled purchaser / exchange runner is actually reached). Both must return +// ErrLadderExecutionDisabled. +// +// It also proves NO AWS call happens: the test runs with no credentials and an +// empty/default AWS config. If the disabled shims delegated to a real SDK client, +// PurchaseLayer / ReshapeBuffer would surface a credentials/network error instead +// of the kill-switch sentinel. Getting ErrLadderExecutionDisabled is proof the +// refusal short-circuits before any outbound call. +func TestWireLadderWriteSide_ExecutionDisabled_RealAWSLadder_RefusesWithoutAWSCall(t *testing.T) { + t.Parallel() + ctx := context.Background() + const ( + region = "us-east-1" + accountID = "123456789012" + ) + + // Real AWSLadder from the production factory (read side wired to real clients; + // construction is offline: the AWS SDK builds clients lazily, no network call). + capRaw, err := awsladder.NewFromAWSConfig(ctx, region, accountID) + require.NoError(t, err) + + // Wire the write side with the kill-switch OFF. app fields are unused on the + // disabled path (WireWriteSideDisabled ignores the exchange adapter), so a + // zero-value Application is sufficient. + app := &Application{} + wired, err := app.wireLadderWriteSide(ctx, false /* executionEnabled */, region, accountID, capRaw) + require.NoError(t, err) + require.NotNil(t, wired) + + // --- PurchaseLayer: valid RI recommendation, kill-switch must refuse. --- + rec := pkgcommon.Recommendation{ + ResourceType: "m5.large", + Count: 2, + Term: "1yr", + PaymentOption: "no-upfront", + Details: &pkgcommon.ComputeDetails{ + InstanceType: "m5.large", + Platform: "linux", + Tenancy: "default", + Scope: "regional", + }, + } + opts := pkgcommon.PurchaseOptions{ + Source: pkgcommon.PurchaseSourceWeb, + IdempotencyToken: "ladder-tok-1", + } + result, purchaseErr := wired.PurchaseLayer(ctx, pkgladder.LayerConvertibleRI, rec, opts) + require.Error(t, purchaseErr) + assert.ErrorIs(t, purchaseErr, awsladder.ErrLadderExecutionDisabled, + "kill-switch OFF must refuse PurchaseLayer with ErrLadderExecutionDisabled, not an AWS error") + assert.False(t, result.Success, "no purchase may be reported as successful when the kill-switch is off") + + // --- ReshapeBuffer: valid scope + config, kill-switch must refuse. --- + scope := pkgladder.Scope{Provider: pkgcommon.ProviderAWS, AccountID: accountID} + reshapeCfg := pkgladder.BufferReshapeConfig{ + MaxPaymentPerExchangeUSD: f64ptr(100.0), + MaxPaymentDailyUSD: f64ptr(500.0), + UtilizationThresholdPct: 20.0, + LookbackDays: 30, + } + _, reshapeErr := wired.ReshapeBuffer(ctx, scope, reshapeCfg) + require.Error(t, reshapeErr) + assert.ErrorIs(t, reshapeErr, awsladder.ErrLadderExecutionDisabled, + "kill-switch OFF must refuse ReshapeBuffer with ErrLadderExecutionDisabled, not an AWS error") +} diff --git a/providers/aws/ladder/factory.go b/providers/aws/ladder/factory.go index 57f3b802c..5e748a2da 100644 --- a/providers/aws/ladder/factory.go +++ b/providers/aws/ladder/factory.go @@ -4,12 +4,17 @@ import ( "context" "fmt" + "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" sdksp "github.com/aws/aws-sdk-go-v2/service/savingsplans" + sptypes "github.com/aws/aws-sdk-go-v2/service/savingsplans/types" + "github.com/LeanerCloud/CUDly/pkg/common" + "github.com/LeanerCloud/CUDly/pkg/exchange" pkgladder "github.com/LeanerCloud/CUDly/pkg/ladder" "github.com/LeanerCloud/CUDly/providers/aws/recommendations" ec2svc "github.com/LeanerCloud/CUDly/providers/aws/services/ec2" + savingsplansvc "github.com/LeanerCloud/CUDly/providers/aws/services/savingsplans" ) // NewFromAWSConfig constructs a fully wired AWSLadder for the given region and @@ -88,3 +93,43 @@ func NewFromAWSConfig(ctx context.Context, region, accountID string) (pkgladder. } return l, nil } + +// disabledPurchaser is the riPurchaser / spPurchaser implementation used when +// ladder_execution_enabled=false. Every call returns ErrLadderExecutionDisabled +// without touching any AWS API. +type disabledPurchaser struct{} + +func (disabledPurchaser) PurchaseCommitment(_ context.Context, _ common.Recommendation, _ common.PurchaseOptions) (common.PurchaseResult, error) { + return common.PurchaseResult{}, fmt.Errorf("%w: purchase blocked by kill-switch", ErrLadderExecutionDisabled) +} + +// disabledExchangeRunner is the exchangeRunner implementation used when +// ladder_execution_enabled=false. Every call returns ErrLadderExecutionDisabled +// without touching any AWS or DB API. +type disabledExchangeRunner struct{} + +func (disabledExchangeRunner) RunAutoExchange(_ context.Context, _ exchange.RIExchangeConfig, _ *string, _ bool) (*exchange.AutoExchangeResult, error) { + return nil, fmt.Errorf("%w: exchange blocked by kill-switch", ErrLadderExecutionDisabled) +} + +// WireWriteSideDisabled wires the ladder's write side with disabled +// implementations that return ErrLadderExecutionDisabled on every call. +// Use this when ladder_execution_enabled=false in global_config: the +// ladder is wired (errWriteNotWired is not returned) but executes nothing +// and allows callers to detect the disabled state via errors.Is. +func WireWriteSideDisabled(l *AWSLadder) (*AWSLadder, error) { + return l.WithWriteSide(disabledPurchaser{}, disabledPurchaser{}, disabledExchangeRunner{}) +} + +// WireWriteSide wires the ladder's write side with real AWS SDK clients. +// Use this when ladder_execution_enabled=true in global_config. +// ex is the exchangeRunner (typically *internal/server.exchangeRunnerAdapter) +// which owns the exchange store, EC2 exchange client, and offering lookup. +// The savingsplans client is created in umbrella mode (empty planType) so a +// single purchaser serves both EC2Instance and Compute SP layers; plan-type +// routing happens at purchase time via resolveSPPlanType. +func WireWriteSide(l *AWSLadder, awsCfg aws.Config, ex exchangeRunner) (*AWSLadder, error) { + riP := ec2svc.NewClient(awsCfg) + spP := savingsplansvc.NewClient(awsCfg, sptypes.SavingsPlanType("")) + return l.WithWriteSide(riP, spP, ex) +} diff --git a/providers/aws/ladder/ladder.go b/providers/aws/ladder/ladder.go index 554760d03..4ba86a233 100644 --- a/providers/aws/ladder/ladder.go +++ b/providers/aws/ladder/ladder.go @@ -25,6 +25,12 @@ const DefaultLookbackDays = 30 // a configuration error at the call site, not a permanent constraint. var errWriteNotWired = errors.New("write side not wired: wire riPurchaser, spPurchaser, and exchangeRunner via WithWriteSide before calling PurchaseLayer or ReshapeBuffer") +// ErrLadderExecutionDisabled is returned by PurchaseLayer and ReshapeBuffer when +// the ladder has been wired with a disabled write side (ladder_execution_enabled=false +// in global_config). Use errors.Is(err, ErrLadderExecutionDisabled) to distinguish +// this from errWriteNotWired (missing wiring = programming error at the call site). +var ErrLadderExecutionDisabled = errors.New("ladder write side disabled: set ladder_execution_enabled=true in global_config to enable purchases and reshapes") + // Config holds construction-time parameters for AWSLadder. type Config struct { // Region is the AWS region this ladder instance operates on. diff --git a/providers/aws/ladder/purchase_test.go b/providers/aws/ladder/purchase_test.go index b23da0d26..3d03d8e90 100644 --- a/providers/aws/ladder/purchase_test.go +++ b/providers/aws/ladder/purchase_test.go @@ -7,6 +7,7 @@ import ( "math" "testing" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -345,3 +346,47 @@ func TestPurchaseLayer_NotSupportedSentinel_PassesThrough(t *testing.T) { assert.ErrorIs(t, err, common.ErrCommitmentPurchaseNotSupported, "engine callers detect permanent inability via errors.Is; wrapping must preserve it") } + +// --------------------------------------------------------------------------- +// Kill-switch (WireWriteSideDisabled / WireWriteSide) tests +// --------------------------------------------------------------------------- + +// TestWireWriteSideDisabled_PurchaseLayer_ReturnsErrLadderExecutionDisabled verifies +// that a ladder wired via WireWriteSideDisabled returns ErrLadderExecutionDisabled +// (not the unwired errWriteNotWired) on PurchaseLayer, and never calls any AWS API. +func TestWireWriteSideDisabled_PurchaseLayer_ReturnsErrLadderExecutionDisabled(t *testing.T) { + t.Parallel() + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + wired, err := WireWriteSideDisabled(a) + require.NoError(t, err) + + _, purchaseErr := wired.PurchaseLayer(context.Background(), ladder.LayerConvertibleRI, validRIRec(), validPurchaseOpts()) + require.Error(t, purchaseErr) + assert.ErrorIs(t, purchaseErr, ErrLadderExecutionDisabled, + "kill-switch must surface as ErrLadderExecutionDisabled, not errWriteNotWired") +} + +// TestWireWriteSideDisabled_ReshapeBuffer_ReturnsErrLadderExecutionDisabled verifies +// the same kill-switch behavior for ReshapeBuffer. +func TestWireWriteSideDisabled_ReshapeBuffer_ReturnsErrLadderExecutionDisabled(t *testing.T) { + t.Parallel() + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + wired, err := WireWriteSideDisabled(a) + require.NoError(t, err) + + _, reshapeErr := wired.ReshapeBuffer(context.Background(), testScope(), validReshapeCfg()) + require.Error(t, reshapeErr) + assert.ErrorIs(t, reshapeErr, ErrLadderExecutionDisabled, + "kill-switch must surface as ErrLadderExecutionDisabled, not errWriteNotWired") +} + +// TestWireWriteSide_EmptyAWSConfig_WiresWithoutPanic verifies that WireWriteSide +// does not panic or error at construction time with an empty aws.Config. AWS +// SDK client constructors are lazy (no credentials or network calls at init). +func TestWireWriteSide_EmptyAWSConfig_WiresWithoutPanic(t *testing.T) { + t.Parallel() + a := newTestLadder(t, &fakeRILister{}, &fakeSPLister{}, &fakeCoverageSource{}, &fakeUtilizationSource{}) + wired, err := WireWriteSide(a, aws.Config{}, &fakeExchangeRunner{}) + require.NoError(t, err) + require.NotNil(t, wired, "WireWriteSide must return a non-nil ladder") +}