Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -539,10 +539,13 @@ describe('Settings Module', () => {
notification_days_before: 3,
// Grace-period inputs default to 7 per provider when the DOM
// doesn't include the new inputs (older test harness setup).
// The save helper reads missing elements as "empty" → default 7.
// The save helper reads missing elements as "empty" -> default 7.
grace_period_days: { aws: 7, azure: 7, gcp: 7 },
recommendations_cache_stale_hours: 24,
recommendations_lookback_days: 7,
// offering_class select is absent in this test harness (no DOM element);
// saveGlobalSettings falls back to 'convertible'.
offering_class: 'convertible',
});
});

Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,10 @@ export interface Config {
// Keys: 'aws' / 'azure' / 'gcp'. A missing key defaults to 7 on
// the backend. Explicit 0 disables the feature for that provider.
grace_period_days?: Record<string, number>;
// EC2 Reserved Instance offering class. "convertible" (default) allows
// future exchanges for different families/sizes/OS; "standard" is
// ~5% cheaper but locked to the exact instance type for the full term.
offering_class?: 'convertible' | 'standard';
ri_exchange_enabled?: boolean;
ri_exchange_mode?: string;
ri_exchange_utilization_threshold?: number;
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,22 @@ <h2>Purchasing Settings</h2>
</div>
</fieldset>

<fieldset class="settings-category" id="ec2-ri-offering-class">
<legend><span class="provider-badge aws">AWS</span> Reserved Instance Class</legend>
<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>
<div class="setting-row">
<div class="setting-info">
<label for="setting-ec2-offering-class">Reserved Instance class</label>
</div>
<div class="setting-input">
<select id="setting-ec2-offering-class">
<option value="convertible" selected>Convertible (default -- exchangeable for different families/sizes/OS later)</option>
<option value="standard">Standard (~5% cheaper, locked to the exact instance for the full term)</option>
</select>
</div>
</div>
</fieldset>

<!-- AWS Settings -->
<fieldset class="settings-category provider-settings" id="aws-settings">
<legend><span class="provider-badge aws">AWS</span> Service Defaults</legend>
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3202,6 +3202,16 @@ export async function loadGlobalSettings(): Promise<void> {
populateGraceInput('setting-grace-azure', gpMap['azure']);
populateGraceInput('setting-grace-gcp', gpMap['gcp']);

// EC2 RI offering class. Absent/null from the API means "convertible"
// (backend default). Only "convertible" and "standard" are valid; any
// other value from a future API version falls back to "convertible" so
// the dropdown always has a valid selection.
const offeringClassSelect = byId<HTMLSelectElement>('setting-ec2-offering-class');
if (offeringClassSelect) {
const oc = data.global.offering_class;
offeringClassSelect.value = (oc === 'standard') ? 'standard' : 'convertible';
}

// Recommendations cycle params
const staleHoursInput = byId<HTMLInputElement>('setting-recs-stale-hours');
if (staleHoursInput) {
Expand Down Expand Up @@ -3476,6 +3486,9 @@ export async function saveGlobalSettings(e: Event): Promise<void> {
return;
}

const rawOfferingClass = byId<HTMLSelectElement>('setting-ec2-offering-class')?.value ?? 'convertible';
const offeringClass: 'convertible' | 'standard' = (rawOfferingClass === 'standard') ? 'standard' : 'convertible';

const settings: api.Config = {
enabled_providers: enabledProviders,
notification_email: byId<HTMLInputElement>('setting-notification-email')?.value || '',
Expand All @@ -3488,6 +3501,7 @@ export async function saveGlobalSettings(e: Event): Promise<void> {
grace_period_days: gracePeriodDays,
recommendations_cache_stale_hours: rawStaleHours,
recommendations_lookback_days: parseInt(byId<HTMLSelectElement>('setting-recs-lookback-days')?.value || '7', 10),
offering_class: offeringClass,
};

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

const offeringClassSelect = byId<HTMLSelectElement>('setting-ec2-offering-class');
if (offeringClassSelect) offeringClassSelect.value = 'convertible';

const staleHoursInput = byId<HTMLInputElement>('setting-recs-stale-hours');
if (staleHoursInput) staleHoursInput.value = '24';
const lookbackSelect = byId<HTMLSelectElement>('setting-recs-lookback-days');
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,8 @@ export interface GlobalConfig {
// suppression feature. Keys: 'aws' / 'azure' / 'gcp'. Missing keys
// fall back to the backend default (7). Explicit 0 = disabled.
grace_period_days?: Record<string, number>;
// EC2 Reserved Instance offering class. "convertible" (default) or "standard".
offering_class?: 'convertible' | 'standard';
// Age (hours) after which the recommendations cache triggers a background
// stale-while-revalidate refresh. 0 disables automatic background refresh.
// Valid range: 0–8760. Default: 24.
Expand Down
21 changes: 17 additions & 4 deletions internal/config/store_postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo
recommendations_cache_stale_hours, recommendations_lookback_days,
COALESCE(purchase_delay_hours, 0),
COALESCE(laddering_enabled, false),
COALESCE(ladder_execution_enabled, false)
COALESCE(ladder_execution_enabled, false),
offering_class
FROM global_config
WHERE id = 1
`
Expand Down Expand Up @@ -113,6 +114,7 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo
&config.PurchaseDelayHours,
&config.LadderingEnabled,
&config.LadderExecutionEnabled,
&config.OfferingClass,
)

if err != nil {
Expand All @@ -135,6 +137,7 @@ func getGlobalConfigFrom(ctx context.Context, q globalConfigExecutor) (*GlobalCo
RecommendationsCacheStaleHours: DefaultRecommendationsCacheStaleHours,
RecommendationsLookbackDays: DefaultRecommendationsLookbackDays,
PurchaseDelayHours: DefaultPurchaseDelayHours,
OfferingClass: "convertible",
}, nil
}
return nil, fmt.Errorf("failed to get global config: %w", err)
Expand Down Expand Up @@ -211,8 +214,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, 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)
purchase_delay_hours, laddering_enabled, ladder_execution_enabled, offering_class
) 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)
ON CONFLICT (id) DO UPDATE SET
enabled_providers = $1,
notification_email = $2,
Expand All @@ -236,6 +239,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
purchase_delay_hours = $20,
laddering_enabled = $21,
ladder_execution_enabled = $22,
offering_class = $23,
updated_at = NOW()
`

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

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

// Default offering_class to "convertible" when unset so the DB column
// never stores an empty string (the NOT NULL DEFAULT 'convertible'
// column handles inserts, but upserts overwrite with whatever we pass).
offeringClass := config.OfferingClass
if offeringClass == "" {
offeringClass = "convertible"
}

_, err := q.Exec(ctx, query,
config.EnabledProviders,
config.NotificationEmail,
Expand All @@ -294,6 +306,7 @@ func saveGlobalConfigWith(ctx context.Context, q globalConfigExecutor, config *G
config.PurchaseDelayHours,
config.LadderingEnabled,
config.LadderExecutionEnabled,
offeringClass,
)

if err != nil {
Expand Down
74 changes: 74 additions & 0 deletions internal/config/store_postgres_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (
"testing"
"time"

"github.com/pashagolub/pgxmock/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

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

assert.True(t, panicked, "expected panic with nil db connection")
}

// TestSaveGlobalConfig_OfferingClassBindsAt23 is the HOLE 2 regression guard
// (issue #694): the real PostgresStore.SaveGlobalConfig must bind offering_class
// as the 23rd positional argument ($23), with laddering_enabled at $21 and
// ladder_execution_enabled at $22.
// The testablePostgresStore in store_postgres_mock_test.go is a hand-maintained
// copy that omits the field entirely, so no fast test guarded the placeholder
// count until now.
//
// Using pgxmock directly against the real PostgresStore (not the hand-maintained
// testablePostgresStore wrapper) ensures the live query is tested, not a stale copy.
func TestSaveGlobalConfig_OfferingClassBindsAt23(t *testing.T) {
ctx := context.Background()
mock, err := pgxmock.NewPool()
require.NoError(t, err)
defer mock.Close()

// Wire the pgxmock pool directly into the real PostgresStore via the
// unexported db field (test is in package config so this is allowed).
store := &PostgresStore{db: mock}

email := "ops@example.com"
cfg := &GlobalConfig{
EnabledProviders: []string{"aws"},
NotificationEmail: &email,
ApprovalRequired: true,
DefaultTerm: 12,
DefaultPayment: "all-upfront",
DefaultCoverage: 80.0,
DefaultRampSchedule: "immediate",
OfferingClass: "standard",
}

// Expect exactly 23 args; pgxmock validates arg count and types.
// The 21st arg is laddering_enabled; the 22nd is ladder_execution_enabled;
// the 23rd arg must be "standard" (offering_class).
// If the real query regresses to a different arg count, pgxmock
// will return an unexpected-call error and the test will fail.
mock.ExpectExec(`INSERT INTO global_config`).
WithArgs(
pgxmock.AnyArg(), // $1 enabled_providers
pgxmock.AnyArg(), // $2 notification_email
pgxmock.AnyArg(), // $3 approval_required
pgxmock.AnyArg(), // $4 default_term
pgxmock.AnyArg(), // $5 default_payment
pgxmock.AnyArg(), // $6 default_coverage
pgxmock.AnyArg(), // $7 default_ramp_schedule
pgxmock.AnyArg(), // $8 ri_exchange_enabled
pgxmock.AnyArg(), // $9 ri_exchange_mode
pgxmock.AnyArg(), // $10 ri_exchange_utilization_threshold
pgxmock.AnyArg(), // $11 ri_exchange_max_per_exchange_usd
pgxmock.AnyArg(), // $12 ri_exchange_max_daily_usd
pgxmock.AnyArg(), // $13 ri_exchange_lookback_days
pgxmock.AnyArg(), // $14 auto_collect
pgxmock.AnyArg(), // $15 collection_schedule
pgxmock.AnyArg(), // $16 notification_days_before
pgxmock.AnyArg(), // $17 grace_period_days
pgxmock.AnyArg(), // $18 recommendations_cache_stale_hours
pgxmock.AnyArg(), // $19 recommendations_lookback_days
pgxmock.AnyArg(), // $20 purchase_delay_hours
pgxmock.AnyArg(), // $21 laddering_enabled
pgxmock.AnyArg(), // $22 ladder_execution_enabled
"standard", // $23 offering_class -- the field this test guards
).
WillReturnResult(pgxmock.NewResult("INSERT", 1))

err = store.SaveGlobalConfig(ctx, cfg)
require.NoError(t, err, "SaveGlobalConfig must succeed when the DB accepts all 23 args")

require.NoError(t, mock.ExpectationsWereMet(),
"offering_class must be bound as the 23rd argument to SaveGlobalConfig")
}
14 changes: 11 additions & 3 deletions internal/config/store_postgres_pgxmock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
"purchase_delay_hours",
"laddering_enabled",
"ladder_execution_enabled",
"offering_class",
}
rows := pgxmock.NewRows(cols).AddRow(
[]string{"aws"}, strPtr("ops@example.com"), true,
Expand All @@ -73,6 +74,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
0,
false,
false,
"convertible",
)
mock.ExpectQuery("SELECT").WillReturnRows(rows)

Expand All @@ -83,6 +85,7 @@ func TestPGXMock_GetGlobalConfig_Success(t *testing.T) {
assert.Equal(t, "ops@example.com", *cfg.NotificationEmail)
assert.Equal(t, 24, cfg.RecommendationsCacheStaleHours)
assert.Equal(t, 7, cfg.RecommendationsLookbackDays)
assert.Equal(t, "convertible", cfg.OfferingClass)
assert.NoError(t, mock.ExpectationsWereMet())
}

Expand Down Expand Up @@ -115,6 +118,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) {
"purchase_delay_hours",
"laddering_enabled",
"ladder_execution_enabled",
"offering_class",
}
baseRow := func(graceJSON string) []any {
return []any{
Expand All @@ -128,6 +132,7 @@ func TestPGXMock_GetGlobalConfig_GracePeriodDays(t *testing.T) {
0,
false,
false,
"convertible",
}
}

Expand Down Expand Up @@ -186,6 +191,7 @@ var globalConfigCols = []string{
"purchase_delay_hours",
"laddering_enabled",
"ladder_execution_enabled",
"offering_class",
}

// TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite proves the F2
Expand Down Expand Up @@ -213,8 +219,9 @@ func TestPGXMock_UpdateGlobalConfigAtomic_LockedReadModifyWrite(t *testing.T) {
"{}",
24, 7,
48,
false, // laddering_enabled = false
false, // ladder_execution_enabled = false
false, // laddering_enabled = false
false, // ladder_execution_enabled = false
"convertible", // offering_class
)

// Strict order: the SELECT and the UPSERT must sit between the same
Expand All @@ -223,7 +230,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(22)...).
mock.ExpectExec("INSERT INTO global_config").WithArgs(anyArgsCfg(23)...).
WillReturnResult(pgxmock.NewResult("INSERT", 1))
mock.ExpectCommit()

Expand Down Expand Up @@ -270,6 +277,7 @@ func TestPGXMock_UpdateGlobalConfigAtomic_ApplyErrorRollsBack(t *testing.T) {
0,
false,
false,
"convertible",
)

mock.ExpectBegin()
Expand Down
10 changes: 9 additions & 1 deletion internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type GlobalConfig struct {

// RecommendationsLookbackDays is the AWS Cost Explorer lookback window
// (days) used when fetching fresh recommendations. Must be one of 7,
// 30, or 60 — the AWS Cost Explorer LookbackPeriodInDays enum.
// 30, or 60 -- the AWS Cost Explorer LookbackPeriodInDays enum.
// GCP CUD Recommender has no equivalent lookback parameter (fixed
// internally); this setting applies to AWS only.
// Default: 7.
Expand Down Expand Up @@ -78,6 +78,14 @@ type GlobalConfig struct {
// 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"`

// 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
// instance family/size/region/OS; Standard RIs are locked to the exact
// instance type for the full term but are ~5% cheaper.
// Unknown values are rejected at purchase time with an explicit error.
OfferingClass string `json:"offering_class,omitempty" dynamodbav:"offering_class,omitempty"`
}

// DefaultGracePeriodDays is the fallback window used when a provider
Expand Down
Loading
Loading