Skip to content

Commit bc4d3ae

Browse files
authored
feat(settings): configurable EC2 RI OfferingClass (closes #694) (#847)
* feat(settings): configurable EC2 RI OfferingClass (closes #694) Add a GlobalConfig.OfferingClass field ("convertible" | "standard") that controls which EC2 Reserved Instance offering class is purchased. Empty / absent values default to "convertible" to preserve pre-694 behaviour. Backend: - pkg/common/types.go: OfferingClass field on PurchaseOptions - internal/config/types.go: OfferingClass on GlobalConfig - internal/config/store_postgres.go: read/write offering_class column ($20) - internal/purchase/execution.go: load GlobalConfig and propagate OfferingClass into PurchaseOptions before the fan-out - providers/aws/services/ec2/client.go: resolveOfferingClassType() fails loudly on unknown values (feedback_empty_string_vs_error.md); empty string maps to convertible so the DB default is safe - internal/database/postgres/migrations/000064_ec2_ri_offering_class: ADD COLUMN offering_class TEXT NOT NULL DEFAULT 'convertible' Frontend: - frontend/src/index.html: fieldset with <select> (Convertible / Standard) - frontend/src/settings.ts: load, save, and reset handlers - frontend/src/api/types.ts + types.ts: offering_class optional field Tests: - TestResolveOfferingClassType: unknown values error, "" = convertible - TestDescribeInputFromQuery_OfferingClass: empty / explicit convertible / standard - All findOfferingID call sites updated to 4-arg signature - store_postgres_pgxmock_test.go: +offering_class column in mock rows - settings.test.ts: offering_class: 'convertible' in saveGlobalSettings expected payload * fix(settings): validate OfferingClass on PUT + assert it reaches the EC2 SDK call (refs #694) - Add validateOfferingClass() to GlobalConfig.Validate() so an invalid offering_class is rejected at PUT time with a clear error rather than silently persisting and only failing at purchase time. Accepts "" | "convertible" | "standard" (case-sensitive, matching resolveOfferingClassType). - Add ValidOfferingClasses exported var for reference/documentation. - Add six table-driven test cases in TestGlobalConfig_Validate covering empty (valid), both valid values, wrong-case "Convertible", "STANDARD", and an unknown value. - Add capturingMockEC2Client embedding MockEC2Client that records the last DescribeReservedInstancesOfferingsInput to enable SDK-level assertions. - Add TestFindOfferingID_OfferingClassReachesSDKCall: three subtests ("", "convertible", "standard") each calling findOfferingID and asserting that the captured OfferingClass on the outbound SDK call equals the expected types.OfferingClassType. This test fails if the wiring from offeringClassStr through resolveOfferingClassType to describeInputFromQuery regresses. * refactor(config): extract validateScheduleAndNotifications to reduce cyclomatic complexity Validate had complexity 11 after adding the OfferingClass check in #694. Extract the CollectionSchedule + NotificationDaysBefore + GracePeriodDays checks into validateScheduleAndNotifications, keeping Validate under 10. * fix(test): avoid mutex copy in TestFindOfferingID_OfferingClassReachesSDKCall Construct capturingMockEC2Client directly instead of copying *MockEC2Client to silence the go vet copylocks warning (MockEC2Client embeds mock.Mock which contains sync.Mutex). * fix(purchase): fail EC2 RI recs on GlobalConfig load error instead of defaulting OfferingClass When GetGlobalConfig returns an error, the old code logged it and continued with an empty OfferingClass, which resolveOfferingClassType maps to "convertible". This caused a transient DB failure to silently buy the wrong (and more expensive, irreversible) RI class instead of the operator-configured "standard". Violates the no-silent-fallbacks-on-money-paths rule. Fix: change processPurchaseRecommendations to return (float64, float64, []string, error) and propagate a hard error on GetGlobalConfig failure. Both call sites (executeSingleAccount and executeForAccount) check the new error return and abort without making any cloud purchase. Regression test (HOLE 1): TestProcessPurchaseRecommendations_GlobalConfigError_FailsInsteadOfDefaulting confirms the function returns an error and never calls the provider factory when GetGlobalConfig fails. Verified red on pre-fix code, green after. Also add TestSaveGlobalConfig_OfferingClassBindsAt21 (HOLE 2): a pgxmock test against the real PostgresStore.SaveGlobalConfig verifying offering_class binds as the 21st positional arg. The hand-maintained testablePostgresStore in store_postgres_mock_test.go omits this field entirely, so no fast test previously guarded the 21-placeholder write query. * refactor(purchase): extract applyAccountOutcome to drop executeForAccount below gocyclo 10 The gocyclo pre-commit hook (-over 10) failed: executeForAccount was at cyclomatic complexity 11. Extract the per-account status-resolution switch (partially_completed / failed / completed stamping, #642) and the committed gate (#1014) into a cohesive applyAccountOutcome helper, dropping the function below the threshold without a //nolint or a threshold bump. Behavior is unchanged: same status transitions, same error-note appending, same committed semantics (anyRecPurchased is now evaluated once and reused for the partial flag). The OfferingClass typed-enum validation and no-silent-fallback behavior are untouched. * fix(test): update SQL parameter positions after laddering_enabled shift Rebasing onto main added laddering_enabled as $21 in global_config INSERT, shifting offering_class from $21 to $22. Update the pgxmock and coverage tests to reflect the new column count and binding positions. * style(purchase,config): clear new-from-rev lint on OfferingClass diff Resolve the three golangci-lint findings that --new-from-rev=origin/main attributes to the #694 OfferingClass changes: - errcheck: replace the silent `_ =` discard of SavePurchaseExecution on the processPurchaseRecommendations error path with the existing saveExecutionStatusBestEffort helper, which persists and logs the audit-save failure instead of dropping it. - gocritic unnamedResult: name the four results of processPurchaseRecommendations (its return set grew to include error); switch the trailing assignment from := to = accordingly. - misspell: pre-694 behaviour -> behavior in the ValidOfferingClasses doc. No behavior change to the purchase path; base-debt Lint/Security failures are pre-existing on main and out of scope. * chore(migration): renumber ec2_ri_offering_class from 000078 to 000083 Migration 000078 collided with PR #828. Main's highest is 000081; #1277 reserves 000082; 000083 is the next free slot for this PR. Renamed via git mv (up + down); no Go code referenced the number directly. * chore(migration): renumber ec2_ri_offering_class from 000083 to 000090 Migration 000083 is now taken by ladder_execution_enabled (merged to main while this PR was in review). Next free slot after 000089_rename_cancelled_to_canceled is 000090. * fix(rebase): restore saveExecutionStatusBestEffort; update param-23 guard test Two issues introduced when rebasing onto main (which added ladder_execution_enabled in parallel): 1. saveExecutionStatusBestEffort helper was dropped during conflict resolution of the applyAccountOutcome refactor commit; restored from the pre-rebase branch. 2. TestSaveGlobalConfig_OfferingClassBindsAt22 expected 22 args; with ladder_execution_enabled now at $22, offering_class moved to $23. Renamed the test to TestSaveGlobalConfig_OfferingClassBindsAt23 and added the $22 AnyArg placeholder.
1 parent 1fca7fd commit bc4d3ae

19 files changed

Lines changed: 579 additions & 55 deletions

‎frontend/src/__tests__/settings.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,10 +539,13 @@ describe('Settings Module', () => {
539539
notification_days_before: 3,
540540
// Grace-period inputs default to 7 per provider when the DOM
541541
// doesn't include the new inputs (older test harness setup).
542-
// The save helper reads missing elements as "empty" → default 7.
542+
// The save helper reads missing elements as "empty" -> default 7.
543543
grace_period_days: { aws: 7, azure: 7, gcp: 7 },
544544
recommendations_cache_stale_hours: 24,
545545
recommendations_lookback_days: 7,
546+
// offering_class select is absent in this test harness (no DOM element);
547+
// saveGlobalSettings falls back to 'convertible'.
548+
offering_class: 'convertible',
546549
});
547550
});
548551

‎frontend/src/api/types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ export interface Config {
289289
// Keys: 'aws' / 'azure' / 'gcp'. A missing key defaults to 7 on
290290
// the backend. Explicit 0 disables the feature for that provider.
291291
grace_period_days?: Record<string, number>;
292+
// EC2 Reserved Instance offering class. "convertible" (default) allows
293+
// future exchanges for different families/sizes/OS; "standard" is
294+
// ~5% cheaper but locked to the exact instance type for the full term.
295+
offering_class?: 'convertible' | 'standard';
292296
ri_exchange_enabled?: boolean;
293297
ri_exchange_mode?: string;
294298
ri_exchange_utilization_threshold?: number;

‎frontend/src/index.html‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,22 @@ <h2>Purchasing Settings</h2>
497497
</div>
498498
</fieldset>
499499

500+
<fieldset class="settings-category" id="ec2-ri-offering-class">
501+
<legend><span class="provider-badge aws">AWS</span> Reserved Instance Class</legend>
502+
<p class="settings-help">Controls whether EC2 Reserved Instances are purchased as Convertible or Standard. Convertible RIs can be exchanged for a different instance family, size, OS, or region later. Standard RIs are approximately 5% cheaper but are locked to the exact instance type for the full term and cannot be exchanged.</p>
503+
<div class="setting-row">
504+
<div class="setting-info">
505+
<label for="setting-ec2-offering-class">Reserved Instance class</label>
506+
</div>
507+
<div class="setting-input">
508+
<select id="setting-ec2-offering-class">
509+
<option value="convertible" selected>Convertible (default -- exchangeable for different families/sizes/OS later)</option>
510+
<option value="standard">Standard (~5% cheaper, locked to the exact instance for the full term)</option>
511+
</select>
512+
</div>
513+
</div>
514+
</fieldset>
515+
500516
<!-- AWS Settings -->
501517
<fieldset class="settings-category provider-settings" id="aws-settings">
502518
<legend><span class="provider-badge aws">AWS</span> Service Defaults</legend>

‎frontend/src/settings.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3202,6 +3202,16 @@ export async function loadGlobalSettings(): Promise<void> {
32023202
populateGraceInput('setting-grace-azure', gpMap['azure']);
32033203
populateGraceInput('setting-grace-gcp', gpMap['gcp']);
32043204

3205+
// EC2 RI offering class. Absent/null from the API means "convertible"
3206+
// (backend default). Only "convertible" and "standard" are valid; any
3207+
// other value from a future API version falls back to "convertible" so
3208+
// the dropdown always has a valid selection.
3209+
const offeringClassSelect = byId<HTMLSelectElement>('setting-ec2-offering-class');
3210+
if (offeringClassSelect) {
3211+
const oc = data.global.offering_class;
3212+
offeringClassSelect.value = (oc === 'standard') ? 'standard' : 'convertible';
3213+
}
3214+
32053215
// Recommendations cycle params
32063216
const staleHoursInput = byId<HTMLInputElement>('setting-recs-stale-hours');
32073217
if (staleHoursInput) {
@@ -3476,6 +3486,9 @@ export async function saveGlobalSettings(e: Event): Promise<void> {
34763486
return;
34773487
}
34783488

3489+
const rawOfferingClass = byId<HTMLSelectElement>('setting-ec2-offering-class')?.value ?? 'convertible';
3490+
const offeringClass: 'convertible' | 'standard' = (rawOfferingClass === 'standard') ? 'standard' : 'convertible';
3491+
34793492
const settings: api.Config = {
34803493
enabled_providers: enabledProviders,
34813494
notification_email: byId<HTMLInputElement>('setting-notification-email')?.value || '',
@@ -3488,6 +3501,7 @@ export async function saveGlobalSettings(e: Event): Promise<void> {
34883501
grace_period_days: gracePeriodDays,
34893502
recommendations_cache_stale_hours: rawStaleHours,
34903503
recommendations_lookback_days: parseInt(byId<HTMLSelectElement>('setting-recs-lookback-days')?.value || '7', 10),
3504+
offering_class: offeringClass,
34913505
};
34923506

34933507
// Include laddering_enabled in the payload when the Purchasing panel's
@@ -3625,6 +3639,9 @@ export async function resetSettings(): Promise<void> {
36253639
populateGraceInput('setting-grace-azure', 7);
36263640
populateGraceInput('setting-grace-gcp', 7);
36273641

3642+
const offeringClassSelect = byId<HTMLSelectElement>('setting-ec2-offering-class');
3643+
if (offeringClassSelect) offeringClassSelect.value = 'convertible';
3644+
36283645
const staleHoursInput = byId<HTMLInputElement>('setting-recs-stale-hours');
36293646
if (staleHoursInput) staleHoursInput.value = '24';
36303647
const lookbackSelect = byId<HTMLSelectElement>('setting-recs-lookback-days');

‎frontend/src/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,8 @@ export interface GlobalConfig {
373373
// suppression feature. Keys: 'aws' / 'azure' / 'gcp'. Missing keys
374374
// fall back to the backend default (7). Explicit 0 = disabled.
375375
grace_period_days?: Record<string, number>;
376+
// EC2 Reserved Instance offering class. "convertible" (default) or "standard".
377+
offering_class?: 'convertible' | 'standard';
376378
// Age (hours) after which the recommendations cache triggers a background
377379
// stale-while-revalidate refresh. 0 disables automatic background refresh.
378380
// Valid range: 0–8760. Default: 24.

‎internal/config/store_postgres.go‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo
8181
recommendations_cache_stale_hours, recommendations_lookback_days,
8282
COALESCE(purchase_delay_hours, 0),
8383
COALESCE(laddering_enabled, false),
84-
COALESCE(ladder_execution_enabled, false)
84+
COALESCE(ladder_execution_enabled, false),
85+
offering_class
8586
FROM global_config
8687
WHERE id = 1
8788
`
@@ -113,6 +114,7 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo
113114
&config.PurchaseDelayHours,
114115
&config.LadderingEnabled,
115116
&config.LadderExecutionEnabled,
117+
&config.OfferingClass,
116118
)
117119

118120
if err != nil {
@@ -135,6 +137,7 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo
135137
RecommendationsCacheStaleHours: DefaultRecommendationsCacheStaleHours,
136138
RecommendationsLookbackDays: DefaultRecommendationsLookbackDays,
137139
PurchaseDelayHours: DefaultPurchaseDelayHours,
140+
OfferingClass: "convertible",
138141
}, nil
139142
}
140143
return nil, fmt.Errorf("failed to get global config: %w", err)
@@ -211,8 +214,8 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
211214
auto_collect, collection_schedule, notification_days_before,
212215
grace_period_days,
213216
recommendations_cache_stale_hours, recommendations_lookback_days,
214-
purchase_delay_hours, laddering_enabled, ladder_execution_enabled
215-
) VALUES (1, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)
217+
purchase_delay_hours, laddering_enabled, ladder_execution_enabled, offering_class
218+
) 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)
216219
ON CONFLICT (id) DO UPDATE SET
217220
enabled_providers = $1,
218221
notification_email = $2,
@@ -236,6 +239,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
236239
purchase_delay_hours = $20,
237240
laddering_enabled = $21,
238241
ladder_execution_enabled = $22,
242+
offering_class = $23,
239243
updated_at = NOW()
240244
`
241245

@@ -259,7 +263,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
259263
riExchangeUtilizationThreshold = 95.0
260264
}
261265

262-
// Marshal GracePeriodDays → JSON text column. Empty map encodes as
266+
// Marshal GracePeriodDays -> JSON text column. Empty map encodes as
263267
// "{}" so the DB column is never NULL and GetGlobalConfig can
264268
// treat "{}" and "" uniformly as "no explicit entries".
265269
gracePeriodJSON := "{}"
@@ -271,6 +275,14 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
271275
gracePeriodJSON = string(gpBytes)
272276
}
273277

278+
// Default offering_class to "convertible" when unset so the DB column
279+
// never stores an empty string (the NOT NULL DEFAULT 'convertible'
280+
// column handles inserts, but upserts overwrite with whatever we pass).
281+
offeringClass := config.OfferingClass
282+
if offeringClass == "" {
283+
offeringClass = "convertible"
284+
}
285+
274286
_, err := q.Exec(ctx, query,
275287
config.EnabledProviders,
276288
config.NotificationEmail,
@@ -294,6 +306,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
294306
config.PurchaseDelayHours,
295307
config.LadderingEnabled,
296308
config.LadderExecutionEnabled,
309+
offeringClass,
297310
)
298311

299312
if err != nil {

‎internal/config/store_postgres_coverage_test.go‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import (
55
"testing"
66
"time"
77

8+
"github.com/pashagolub/pgxmock/v4"
89
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
911
)
1012

1113
// These tests exercise the real PostgresStore methods to gain code coverage.
@@ -535,3 +537,75 @@ func TestPostgresStore_GetAllPurchaseHistory_NilDB(t *testing.T) {
535537

536538
assert.True(t, panicked, "expected panic with nil db connection")
537539
}
540+
541+
// TestSaveGlobalConfig_OfferingClassBindsAt23 is the HOLE 2 regression guard
542+
// (issue #694): the real PostgresStore.SaveGlobalConfig must bind offering_class
543+
// as the 23rd positional argument ($23), with laddering_enabled at $21 and
544+
// ladder_execution_enabled at $22.
545+
// The testablePostgresStore in store_postgres_mock_test.go is a hand-maintained
546+
// copy that omits the field entirely, so no fast test guarded the placeholder
547+
// count until now.
548+
//
549+
// Using pgxmock directly against the real PostgresStore (not the hand-maintained
550+
// testablePostgresStore wrapper) ensures the live query is tested, not a stale copy.
551+
func TestSaveGlobalConfig_OfferingClassBindsAt23(t *testing.T) {
552+
ctx := context.Background()
553+
mock, err := pgxmock.NewPool()
554+
require.NoError(t, err)
555+
defer mock.Close()
556+
557+
// Wire the pgxmock pool directly into the real PostgresStore via the
558+
// unexported db field (test is in package config so this is allowed).
559+
store := &PostgresStore{db: mock}
560+
561+
email := "ops@example.com"
562+
cfg := &GlobalConfig{
563+
EnabledProviders: []string{"aws"},
564+
NotificationEmail: &email,
565+
ApprovalRequired: true,
566+
DefaultTerm: 12,
567+
DefaultPayment: "all-upfront",
568+
DefaultCoverage: 80.0,
569+
DefaultRampSchedule: "immediate",
570+
OfferingClass: "standard",
571+
}
572+
573+
// Expect exactly 23 args; pgxmock validates arg count and types.
574+
// The 21st arg is laddering_enabled; the 22nd is ladder_execution_enabled;
575+
// the 23rd arg must be "standard" (offering_class).
576+
// If the real query regresses to a different arg count, pgxmock
577+
// will return an unexpected-call error and the test will fail.
578+
mock.ExpectExec(`INSERT INTO global_config`).
579+
WithArgs(
580+
pgxmock.AnyArg(), // $1 enabled_providers
581+
pgxmock.AnyArg(), // $2 notification_email
582+
pgxmock.AnyArg(), // $3 approval_required
583+
pgxmock.AnyArg(), // $4 default_term
584+
pgxmock.AnyArg(), // $5 default_payment
585+
pgxmock.AnyArg(), // $6 default_coverage
586+
pgxmock.AnyArg(), // $7 default_ramp_schedule
587+
pgxmock.AnyArg(), // $8 ri_exchange_enabled
588+
pgxmock.AnyArg(), // $9 ri_exchange_mode
589+
pgxmock.AnyArg(), // $10 ri_exchange_utilization_threshold
590+
pgxmock.AnyArg(), // $11 ri_exchange_max_per_exchange_usd
591+
pgxmock.AnyArg(), // $12 ri_exchange_max_daily_usd
592+
pgxmock.AnyArg(), // $13 ri_exchange_lookback_days
593+
pgxmock.AnyArg(), // $14 auto_collect
594+
pgxmock.AnyArg(), // $15 collection_schedule
595+
pgxmock.AnyArg(), // $16 notification_days_before
596+
pgxmock.AnyArg(), // $17 grace_period_days
597+
pgxmock.AnyArg(), // $18 recommendations_cache_stale_hours
598+
pgxmock.AnyArg(), // $19 recommendations_lookback_days
599+
pgxmock.AnyArg(), // $20 purchase_delay_hours
600+
pgxmock.AnyArg(), // $21 laddering_enabled
601+
pgxmock.AnyArg(), // $22 ladder_execution_enabled
602+
"standard", // $23 offering_class -- the field this test guards
603+
).
604+
WillReturnResult(pgxmock.NewResult("INSERT", 1))
605+
606+
err = store.SaveGlobalConfig(ctx, cfg)
607+
require.NoError(t, err, "SaveGlobalConfig must succeed when the DB accepts all 23 args")
608+
609+
require.NoError(t, mock.ExpectationsWereMet(),
610+
"offering_class must be bound as the 23rd argument to SaveGlobalConfig")
611+
}

‎internal/config/store_postgres_pgxmock_test.go‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
6161
"purchase_delay_hours",
6262
"laddering_enabled",
6363
"ladder_execution_enabled",
64+
"offering_class",
6465
}
6566
rows := pgxmock.NewRows(cols).AddRow(
6667
[]string{"aws"}, strPtr("ops@example.com"), true,
@@ -73,6 +74,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
7374
0,
7475
false,
7576
false,
77+
"convertible",
7678
)
7779
mock.ExpectQuery("SELECT").WillReturnRows(rows)
7880

@@ -83,6 +85,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
8385
assert.Equal(t, "ops@example.com", *cfg.NotificationEmail)
8486
assert.Equal(t, 24, cfg.RecommendationsCacheStaleHours)
8587
assert.Equal(t, 7, cfg.RecommendationsLookbackDays)
88+
assert.Equal(t, "convertible", cfg.OfferingClass)
8689
assert.NoError(t, mock.ExpectationsWereMet())
8790
}
8891

@@ -115,6 +118,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) {
115118
"purchase_delay_hours",
116119
"laddering_enabled",
117120
"ladder_execution_enabled",
121+
"offering_class",
118122
}
119123
baseRow := func(graceJSON string) []any {
120124
return []any{
@@ -128,6 +132,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) {
128132
0,
129133
false,
130134
false,
135+
"convertible",
131136
}
132137
}
133138

@@ -186,6 +191,7 @@ var globalConfigCols = []string{
186191
"purchase_delay_hours",
187192
"laddering_enabled",
188193
"ladder_execution_enabled",
194+
"offering_class",
189195
}
190196

191197
// TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite proves the F2
@@ -213,8 +219,9 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) {
213219
"{}",
214220
24, 7,
215221
48,
216-
false, // laddering_enabled = false
217-
false, // ladder_execution_enabled = false
222+
false, // laddering_enabled = false
223+
false, // ladder_execution_enabled = false
224+
"convertible", // offering_class
218225
)
219226

220227
// Strict order: the SELECT and the UPSERT must sit between the same
@@ -223,7 +230,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) {
223230
mock.ExpectExec("pg_advisory_xact_lock").WithArgs(pgxmock.AnyArg()).
224231
WillReturnResult(pgxmock.NewResult("SELECT", 1))
225232
mock.ExpectQuery("FROM global_config").WillReturnRows(seeded)
226-
mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(22)...).
233+
mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(23)...).
227234
WillReturnResult(pgxmock.NewResult("INSERT", 1))
228235
mock.ExpectCommit()
229236

@@ -270,6 +277,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_ApplyErrorRollsBack(t *testing.T) {
270277
0,
271278
false,
272279
false,
280+
"convertible",
273281
)
274282

275283
mock.ExpectBegin()

‎internal/config/types.go‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ type GlobalConfig struct {
5050

5151
// RecommendationsLookbackDays is the AWS Cost Explorer lookback window
5252
// (days) used when fetching fresh recommendations. Must be one of 7,
53-
// 30, or 60 — the AWS Cost Explorer LookbackPeriodInDays enum.
53+
// 30, or 60 -- the AWS Cost Explorer LookbackPeriodInDays enum.
5454
// GCP CUD Recommender has no equivalent lookback parameter (fixed
5555
// internally); this setting applies to AWS only.
5656
// Default: 7.
@@ -78,6 +78,14 @@ type GlobalConfig struct {
7878
// operator explicitly opts in. Fail-loud: wireLadderWriteSide returns
7979
// a typed ErrLadderExecutionDisabled when this is false.
8080
LadderExecutionEnabled bool `json:"ladder_execution_enabled" db:"ladder_execution_enabled"`
81+
82+
// OfferingClass controls the EC2 Reserved Instance offering class used
83+
// during purchase. Accepted values: "convertible" (default) and
84+
// "standard". Convertible RIs can be exchanged for a different
85+
// instance family/size/region/OS; Standard RIs are locked to the exact
86+
// instance type for the full term but are ~5% cheaper.
87+
// Unknown values are rejected at purchase time with an explicit error.
88+
OfferingClass string `json:"offering_class,omitempty" dynamodbav:"offering_class,omitempty"`
8189
}
8290

8391
// DefaultGracePeriodDays is the fallback window used when a provider

0 commit comments

Comments
 (0)