Summary
The EC2 RI purchase path hardcodes OfferingClass: types.OfferingClassTypeConvertible (providers/aws/services/ec2/client.go:381, inside describeInputFromQuery). Make this configurable per tenant via the existing Settings UI, defaulting to Convertible (current behaviour).
Motivation
Convertible RIs are the safer default -- they can be exchanged for different instance families/sizes/regions/OS as workloads evolve. But Standard RIs are ~5% cheaper for the same coverage. A tenant that has stable, well-characterised workloads and never expects to need exchange may prefer Standard for the extra savings; today they have no way to express that and CUDly always buys Convertible.
Scope
Backend
- Add
OfferingClass string (or RIOfferingClass) to internal/config/types.go GlobalConfig struct, JSON-tagged offering_class. Values: "convertible" (default) or "standard".
- Add a SQL migration mirroring
000036_purchase_grace_period.up.sql: ALTER TABLE global_config ADD COLUMN IF NOT EXISTS offering_class TEXT NOT NULL DEFAULT 'convertible' plus the matching .down.sql.
- Wire
store_postgres.go SELECT and INSERT/UPDATE statements to round-trip the new column.
- Replace the hardcoded
types.OfferingClassTypeConvertible in describeInputFromQuery with a value resolved from the loaded GlobalConfig, mapping "convertible" -> types.OfferingClassTypeConvertible and "standard" -> types.OfferingClassTypeStandard. Validate the config value at load time -- unknown values must surface an explicit error, never silently coerce (per feedback_empty_string_vs_error.md).
- Delete the always-returns-"convertible"
getOfferingClass helper if it is now unused (verify git grep after the wiring).
Exchange listing
ListConvertibleReservedInstances (providers/aws/services/ec2/client.go:601) filters by offering-class=convertible server-side. With the new setting it makes sense to:
- keep this method as-is (Standard RIs cannot be exchanged, so the exchange-list filter on Convertible stays correct), OR
- rename to
ListExchangeableReservedInstances for clarity and document that Standard RIs are intentionally excluded.
API
internal/api/handler_* endpoints that return / accept settings: include the new field on GET and validate on PUT.
internal/api/handler_ri_exchange.go:246 currently sets OfferingClass: "convertible" on the exchange-record shape. Audit whether this stays a fixed string or also needs to read from config (probably stays fixed -- the exchange flow only ever applies to Convertible RIs).
Frontend
- Add a setting to
frontend/src/settings.ts mirroring the existing grace-period pattern (frontend/src/__tests__/settings-purchasing-grace.test.ts).
- UI label: "Reserved Instance class" with two options: "Convertible (default -- exchangeable for different families/sizes/OS later)" and "Standard (~5% cheaper, locked to the exact instance for the full term)".
- Default to Convertible if the API returns null/missing.
- Add a frontend test mirroring
settings-purchasing-grace.test.ts.
Tests
- Unit test for
describeInputFromQuery (or its caller) asserting OfferingClass reflects the config value.
- Integration test (or mock-level) asserting a Standard-configured purchase actually sends
OfferingClass=Standard to AWS.
- Settings round-trip test on
store_postgres.go.
Out of scope
- Per-recommendation override (the dropdown only on individual purchase rows). Possible follow-up if customers ask for finer-grained control; tenant-level is enough for the first cut.
- Standard-RI -> Convertible-RI migration helper. Standard RIs cannot be converted; that is intentional and a property of AWS, not something CUDly can or should mask.
- Offering-class fields on services other than EC2. RDS / ElastiCache / OpenSearch / Redshift / MemoryDB don't expose a comparable Convertible/Standard split via the AWS APIs CUDly uses.
Acceptance criteria
Cross-references
Summary
The EC2 RI purchase path hardcodes
OfferingClass: types.OfferingClassTypeConvertible(providers/aws/services/ec2/client.go:381, insidedescribeInputFromQuery). Make this configurable per tenant via the existing Settings UI, defaulting to Convertible (current behaviour).Motivation
Convertible RIs are the safer default -- they can be exchanged for different instance families/sizes/regions/OS as workloads evolve. But Standard RIs are ~5% cheaper for the same coverage. A tenant that has stable, well-characterised workloads and never expects to need exchange may prefer Standard for the extra savings; today they have no way to express that and CUDly always buys Convertible.
Scope
Backend
OfferingClass string(orRIOfferingClass) tointernal/config/types.goGlobalConfigstruct, JSON-taggedoffering_class. Values:"convertible"(default) or"standard".000036_purchase_grace_period.up.sql:ALTER TABLE global_config ADD COLUMN IF NOT EXISTS offering_class TEXT NOT NULL DEFAULT 'convertible'plus the matching.down.sql.store_postgres.goSELECT and INSERT/UPDATE statements to round-trip the new column.types.OfferingClassTypeConvertibleindescribeInputFromQuerywith a value resolved from the loadedGlobalConfig, mapping"convertible"->types.OfferingClassTypeConvertibleand"standard"->types.OfferingClassTypeStandard. Validate the config value at load time -- unknown values must surface an explicit error, never silently coerce (perfeedback_empty_string_vs_error.md).getOfferingClasshelper if it is now unused (verifygit grepafter the wiring).Exchange listing
ListConvertibleReservedInstances(providers/aws/services/ec2/client.go:601) filters byoffering-class=convertibleserver-side. With the new setting it makes sense to:ListExchangeableReservedInstancesfor clarity and document that Standard RIs are intentionally excluded.API
internal/api/handler_*endpoints that return / accept settings: include the new field on GET and validate on PUT.internal/api/handler_ri_exchange.go:246currently setsOfferingClass: "convertible"on the exchange-record shape. Audit whether this stays a fixed string or also needs to read from config (probably stays fixed -- the exchange flow only ever applies to Convertible RIs).Frontend
frontend/src/settings.tsmirroring the existing grace-period pattern (frontend/src/__tests__/settings-purchasing-grace.test.ts).settings-purchasing-grace.test.ts.Tests
describeInputFromQuery(or its caller) assertingOfferingClassreflects the config value.OfferingClass=Standardto AWS.store_postgres.go.Out of scope
Acceptance criteria
GlobalConfig+ migration appliedCross-references
frontend/src/__tests__/settings-purchasing-grace.test.ts-- the prior-art pattern to mirror