Skip to content

chore(common): normalize Savings Plans identifier to "savingsplans" (frontend canonical)#94

Merged
cristim merged 3 commits intofeat/multicloud-web-frontendfrom
chore/normalize-savings-plans-identifier
Apr 30, 2026
Merged

chore(common): normalize Savings Plans identifier to "savingsplans" (frontend canonical)#94
cristim merged 3 commits intofeat/multicloud-web-frontendfrom
chore/normalize-savings-plans-identifier

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented Apr 26, 2026

Summary

Normalises common.ServiceSavingsPlans from the hyphenated "savings-plans" to the frontend canonical "savingsplans" so that direct comparisons (rec.Service == common.ServiceSavingsPlans) work without the historical normaliser shim. Picks issue #85 Option B (frontend wins) to avoid a SQL data migration on service_configs.service / purchase_history.service.

Refs #85 (the issue's hard removal of the dual-case branch is intentionally deferred — see "Why Refs not Closes" below).

Pre-flight findings

Tree-wide grep for savings-plans / savingsplans / ServiceSavingsPlans:

Surface Form used Action
Frontend (frontend/src/**) "savingsplans" everywhere no change
Frontend dropdown / SERVICE_FIELDS / commitmentConfigs "savingsplans" no change
Backend constant pkg/common/types.go:49 was "savings-plans" flipped to "savingsplans"
Backend constant test pkg/common/types_test.go:42 asserted "savings-plans" flipped + regression-guard comment
internal/purchase/execution.go:384 dual-case "savings-plans", "savingsplans" retained (see below)
internal/purchase/coverage_extra_test.go both inputs map to constant retained
Every other Go callsite (40+) uses the constant common.ServiceSavingsPlans symbolically unchanged — compiles against new value automatically
cmd/main.go::parseServices map already "savingsplans" no change
DB migrations / schema service VARCHAR(64), no CHECK, no seed data with either form no migration needed
Audit log writes (pkg/common/audit.go) write-only, never read back into Go safe

Why the dual-case in execution.go is retained (deferred from #85's acceptance)

scheduler.go:834 and audit.go:50 serialise rec.Service as string(rec.Service) into the purchase_executions.recommendations JSONB column. Any Lambda-scheduled Savings Plans execution persisted before this PR carries "service": "savings-plans" in that JSONB blob. On retry / approval (internal/purchase/execution.go:231 iterates exec.Recommendations), each rec is re-fed through mapServiceType(). Removing the legacy alias arm now would silently break those rows by mapping them to ServiceType("savings-plans") instead of ServiceSavingsPlans, after which cloudProvider.GetServiceClient(ctx, serviceType, …) would fail.

Trade-off:

  • Short-term: keep the dual-case arm with a clear comment, a TODO(#85) marker, and a follow-up issue tracking eventual removal once historical rows have aged out (~6 months retention). The constant is canonical; the alias is purely a read-side shim at the API boundary.
  • Alternative considered: a one-shot SQL UPDATE rewriting purchase_executions.recommendations JSONB to flip "savings-plans""savingsplans". Out of scope for this PR (matches issue Normalize 'savings-plans' vs 'savingsplans' identifier across frontend + backend #85's explicit "Option B" preference of avoiding migrations).

The regression test in pkg/common/types_test.go will fail if anyone flips the constant back, so the canonical never silently drifts again.

Changes

  • pkg/common/types.goServiceSavingsPlans = "savingsplans"; expanded godoc explains rationale + points back at Normalize 'savings-plans' vs 'savingsplans' identifier across frontend + backend #85.
  • pkg/common/types_test.go — regression guard asserts "savingsplans".
  • internal/purchase/execution.gomapServiceType keeps both "savingsplans" and "savings-plans" with a documented backwards-compat block + TODO(#85) for removal.
  • internal/purchase/coverage_extra_test.go — legacy "savings-plans" input case retained and re-documented.

Net diff: 30 insertions, 5 deletions across 4 files.

Verification

  • go build ./... clean from the root and from each submodule (pkg/, providers/aws, providers/azure, providers/gcp).
  • go test ./... from root + pkg/ submodule: all tests pass that were passing before.
  • go test ./providers/aws/...: the only AWS-side failures are TestAWSProvider_GetDefaultRegion/*, which are pre-existing environment-leak bugs unrelated to this PR (the test mutates aws.Config.Region but IsConfigured() clobbers it from the SDK default-credentials chain reading ~/.aws/config). TestSavingsPlans* and TestAWSProvider_GetSupportedServices (which touches ServiceSavingsPlans directly) all pass.
  • go test ./providers/gcp/...: pre-existing failure in TestMemorystoreClient_GetExistingCommitments_WithMockService is unrelated to Savings Plans.
  • go vet ./... clean.
  • Pre-commit hooks (gofmt, go mod tidy, go vet, gosec, trivy, AWS secrets scan, cyclomatic complexity, full Go test suite) all pass.

Why Refs #85 not Closes #85

Issue #85's acceptance includes "The dual-case branch in execution.go:384 removed." This PR retains the dual-case as a backwards-compat alias for persisted Lambda-scheduled rows (rationale above). The follow-up issue tracking the eventual removal will be filed once this lands and the post-merge verification completes.

Test plan

  • CI green
  • CodeRabbit review settles
  • Post-merge: deploy to lambda URL, verify the Savings Plans dropdown still saves to service_configs (it should — frontend was already writing "savingsplans"); the round-trip read should now compare equal to common.ServiceSavingsPlans without the normaliser

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of Savings Plans naming so historical records using the legacy hyphenated form are recognized alongside the new canonical form, preserving correct display and behavior for existing purchases and recommendations.
    • Internal mapping and tests updated to ensure backward compatibility with persisted data; no visible changes to users.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 05f7d363-3a07-4fc7-bdc4-37c2248d8346

📥 Commits

Reviewing files that changed from the base of the PR and between ab8290e and 768ff82.

📒 Files selected for processing (1)
  • internal/purchase/execution.go
✅ Files skipped from review due to trivial changes (1)
  • internal/purchase/execution.go

📝 Walkthrough

Walkthrough

The PR renames the canonical Go ServiceSavingsPlans identifier from "savings-plans" to "savingsplans" and updates tests and documentation. It preserves backward compatibility by mapping the legacy hyphenated "savings-plans" string to the new constant where persisted data may still use the old form.

Changes

Cohort / File(s) Summary
Constant Normalization
pkg/common/types.go, pkg/common/types_test.go
Changed ServiceSavingsPlans value from "savings-plans""savingsplans" and updated test expectation and comments to document the canonical identifier.
Legacy Mapper Support & Tests
internal/purchase/execution.go, internal/purchase/coverage_extra_test.go
Documented the legacy "savings-plans" alias in mapSavingsPlansSlug/mapServiceType and preserved a test assertion ensuring "savings-plans" still maps to common.ServiceSavingsPlans for existing persisted rows.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Poem

🐰 I nudge the names from dash to plain,
Old rows still call and I explain,
A gentle mapper keeps the past,
New constant set — the change is fast,
Hop, hop, backward-compat at last!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately reflects the main change: renaming the backend ServiceSavingsPlans constant from 'savings-plans' to 'savingsplans' to match the frontend canonical identifier.
Linked Issues check ✅ Passed The PR implements Option B from issue #85: adopts 'savingsplans' as canonical, retains backward-compat alias for persisted rows, removes dual-case branch, and adds regression test in types_test.go.
Out of Scope Changes check ✅ Passed All changes directly support the canonical identifier normalization goal: constant rename, alias mapping with TODO documentation, and aligned test updates. No unrelated changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chore/normalize-savings-plans-identifier

Review rate limit: 3/5 reviews remaining, refill in 14 minutes and 11 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 26, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 28, 2026

Merge conflict detected (mergeStateStatus: DIRTY) — rebase needed. The dual-case backwards-compat alias in execution.go is intentionally retained (documented reasoning re: persisted JSONB rows); this is correct per the Refs #85 (not Closes) framing. (triage agent wave2-E)

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 29, 2026

Heads-up: this PR may need a substantive rework or closure.

Attempted a rebase onto current feat/multicloud-web-frontend while clearing the queue. The rebase aborted with semantic conflicts in pkg/common/types.go, pkg/common/types_test.go, and internal/purchase/execution.go.

Root cause: base branch shipped the per-plan-type Savings Plans split (migration 000040_split_savingsplans.up.sql + new constants ServiceSavingsPlansCompute, …EC2Instance, …SageMaker, …Database). All four new constants use the hyphenated convention ("savings-plans-compute" etc.), and ServiceSavingsPlans is now documented as the legacy umbrella being removed by the migration.

This PR's intent — flip the umbrella slug from "savings-plans" to "savingsplans" to match the frontend — is undermined:

  1. The umbrella row is being deleted by migration perf(exchange): replace peerFamilyGroups allowlist + per-rec offering API with cached Cost Explorer recommendations #40 anyway.
  2. Renaming only the umbrella while leaving the four new per-plan-type constants hyphenated would create a worse inconsistency.
  3. The migration explicitly accepts both spellings as legacy input on the DELETE path.

A correct rebase would require a product call: rename all five SP-related ServiceType values to a dash-free convention (and the migration that consumes them, plus the frontend) — much bigger than the original chore.

Suggest either closing this PR (the SP split made the umbrella moot), or reworking it to flip ALL five SP-related ServiceType values + frontend + migration in lockstep.

…frontend canonical)

Aligns common.ServiceSavingsPlans with the value the frontend already
persists ("savingsplans") and removes the foot-gun where any new code
comparing rec.Service == common.ServiceSavingsPlans would silently miss
Savings Plans rows. Picks the frontend canonical (issue #85 Option B) to
avoid a SQL data migration on service_configs.service / purchase_history.

Pre-flight findings (grep over the whole tree):
- frontend already uses "savingsplans" everywhere — no frontend changes.
- the only Go string literals using the hyphenated form were the
  constant declaration, its constant-value test, the dual-case branch in
  the purchase-execution mapper, and the matching test case for that
  mapper.
- service_configs.service is VARCHAR(64) with no CHECK constraint and no
  seed data; the frontend has always written "savingsplans".
- purchase_executions.recommendations is JSONB and rec.Service is
  serialised as `string(rec.Service)` (scheduler.go, audit.go), so any
  Lambda-scheduled SP execution persisted before this change carries
  rec.Service == "savings-plans" in the JSONB blob and is re-fed through
  mapServiceType on retry / approval. The dual-case in execution.go is
  therefore retained as a backwards-compat alias (with a TODO and a
  follow-up issue) instead of being removed; the persistent rows are the
  reason the issue's hard "remove the dual-case branch" couldn't be
  satisfied in a single PR.

Changes:
- pkg/common/types.go: ServiceSavingsPlans value flipped from
  "savings-plans" to "savingsplans"; expanded godoc explains the
  rationale and points back at the issue.
- pkg/common/types_test.go: regression guard now asserts the constant
  value is "savingsplans" so any future flip back is a deliberate change
  that requires a SQL migration.
- internal/purchase/execution.go: mapServiceType keeps both
  "savingsplans" and "savings-plans" as legacy alias, with a comment
  block + TODO(#85) explaining when it can be removed.
- internal/purchase/coverage_extra_test.go: legacy "savings-plans" input
  case retained and re-documented.

Refs #85
@cristim cristim force-pushed the chore/normalize-savings-plans-identifier branch from 0b321f3 to ab8290e Compare April 30, 2026 14:10
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 30, 2026

Rebased onto current feat/multicloud-web-frontend (now at e6c08308b). Force-pushed 0b321f371 → ab8290e4d.

Conflicts resolved (3 files):

go build ./... clean. go test ./... (root) and (cd pkg && go test ./...) both green. No drive-by changes.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

@cristim: Thanks for the detailed rebase notes. Kicking off a fresh incremental review against the rebased head (ab8290e4d).

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
internal/purchase/execution.go (1)

387-397: ⚡ Quick win

Consolidate Savings Plans alias mapping to one path.

Line 371 already maps both values through mapSavingsPlansSlug, so Lines 396-397 are dead code. Keeping duplicate alias logic makes TODO(#85) retirement easy to do incorrectly.

Suggested diff
-	// Backwards-compat: "savings-plans" was the value of
-	// common.ServiceSavingsPlans before issue `#85` normalised the constant to
-	// match the frontend ("savingsplans"). Lambda-scheduled purchase
-	// executions persisted before that change carry rec.Service ==
-	// "savings-plans" in the purchase_executions.recommendations JSONB blob
-	// and are re-fed through this mapper on retry / approval. Keep the
-	// legacy alias accepted here so historical rows still execute correctly.
-	// TODO(`#85`): drop the "savings-plans" alias once no purchase_executions
-	// rows older than the `#85` cutoff remain (~6 months retention).
-	case "savingsplans", "savings-plans":
-		return common.ServiceSavingsPlans
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/purchase/execution.go` around lines 387 - 397, Remove the duplicate
"savings-plans"/"savingsplans" case branch in the switch inside the purchase
execution mapper and rely solely on mapSavingsPlansSlug to normalize the slug;
ensure mapSavingsPlansSlug explicitly accepts both "savings-plans" and
"savingsplans" and returns common.ServiceSavingsPlans, then delete the redundant
case returning common.ServiceSavingsPlans so the legacy alias logic is
centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/purchase/execution.go`:
- Around line 387-397: Remove the duplicate "savings-plans"/"savingsplans" case
branch in the switch inside the purchase execution mapper and rely solely on
mapSavingsPlansSlug to normalize the slug; ensure mapSavingsPlansSlug explicitly
accepts both "savings-plans" and "savingsplans" and returns
common.ServiceSavingsPlans, then delete the redundant case returning
common.ServiceSavingsPlans so the legacy alias logic is centralized.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c63f78c9-6ad0-4478-9d48-10a050255354

📥 Commits

Reviewing files that changed from the base of the PR and between e6c0830 and ab8290e.

📒 Files selected for processing (4)
  • internal/purchase/coverage_extra_test.go
  • internal/purchase/execution.go
  • pkg/common/types.go
  • pkg/common/types_test.go

…rviceType (CR pass 1)

CR pass 1 on PR #94 flagged that the case I added for the legacy
alias is unreachable. Verified:

* mapServiceType opens with `if svc, ok := mapSavingsPlansSlug(service); ok { return svc }`.
* mapSavingsPlansSlug explicitly maps both "savings-plans" and
  "savingsplans" to common.ServiceSavingsPlans (and the per-plan-type
  spellings to their typed constants).

So by the time control reaches the switch, those values can never
appear — my added case was dead code, and keeping it made the future
TODO(#85) retirement easy to do incorrectly (delete the dead case
and miss the real mapping site).

Removed the case + its TODO comment block. The legacy alias
behaviour is unchanged: pre-#85 purchase_executions JSONB rows whose
rec.Service == "savings-plans" still resolve via mapSavingsPlansSlug.

Verified: go test ./internal/purchase/... passes; full repo
go test ./... shows no regressions.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 30, 2026

CR pass 1 — pushed 46caef74e.

Addressed (1 nitpick):

internal/purchase/execution.go:387-397 — confirmed unreachable. mapServiceType opens with:

if svc, ok := mapSavingsPlansSlug(service); ok { return svc }

and mapSavingsPlansSlug (lines 408-423) maps both "savings-plans" and "savingsplans" to common.ServiceSavingsPlans (plus the four per-plan-type spellings). So the case I added in the rebase resolution was dead code — and CR's "easy to retire incorrectly" concern about the future TODO(#85) cleanup is real (delete the dead case and miss the actual mapping site).

Removed the case + its TODO comment block. Behaviour unchanged: pre-#85 purchase_executions JSONB rows whose rec.Service == "savings-plans" still resolve via mapSavingsPlansSlug.

The TODO content is now arguably better placed inside mapSavingsPlansSlug (the canonical alias site). Happy to add a brief comment there if you want — calling it out so CR doesn't flag the loss-of-context.

go test ./... clean. Pre-commit (gosec / trivy / go-test) all green.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

@cristim: Good catch and clean resolution — removing the dead case is the right call. Since mapSavingsPlansSlug is now the sole canonical alias site for both "savings-plans" and "savingsplans", a brief comment there would be the natural home for the TODO(#85) context. Something like:

// TODO(`#85`): once purchase_executions JSONB rows persisted before the "savingsplans"
// rename (~6-month retention window) have aged out, the "savings-plans" alias below
// can be removed and only "savingsplans" need be matched here.

That way a future maintainer deleting the alias has the full rationale inline at the actual code path, rather than having to grep for it. Happy to trigger the review now — you can decide whether to add the comment in this pass or a follow-up.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…Slug (CR pass 2)

Per CR pass 2 follow-up: the previous commit removed a dead case
that carried the TODO(#85) context. mapSavingsPlansSlug is now the
sole canonical alias site for "savings-plans" → "savingsplans"
mapping, so the TODO belongs there — a future maintainer deleting
the alias has the full rationale (6-month retention, umbrella came
from #94, per-plan-type slugs are forward-compat) inline at the
actual code path, rather than having to grep for it.

Comment-only change. No behaviour change. No new tests needed.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 30, 2026

Going with the inline-comment approach over an issue. Pushed 768ff82f2.

Reasoning: a 4-line block-comment in mapSavingsPlansSlug is cheaper than an issue and lands the rationale where the maintainer will actually be looking — the canonical alias site itself. An issue without a corresponding code anchor would just push the future maintainer through git log -S "savings-plans" archaeology to find the rationale; the inline TODO eliminates that step. The PR's scope already IS the rename, so alias-retirement docs are in-scope for the same change.

Comment captures three pieces of context at the point of action:

  1. When to retire (~6-month retention window for pre-Normalize 'savings-plans' vs 'savingsplans' identifier across frontend + backend #85 purchase_executions JSONB rows)
  2. Where the rename came from (PR chore(common): normalize Savings Plans identifier to "savingsplans" (frontend canonical) #94 — this PR)
  3. Forward-compat note for the per-plan-type spellings (savingsplans-compute etc. — those slugs were always dash-form on the wire, so the dash-free aliases here are speculative for any future frontend canonicalisation, NOT a Normalize 'savings-plans' vs 'savingsplans' identifier across frontend + backend #85-era backfill)

That last bit is the one that would have been hardest to recover from git archaeology alone, so worth committing now.

go build ./... clean, go test ./... clean. Comment-only — no behaviour change, no new tests.

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

@cristim: Solid rationale, and the three-point structure in the comment is exactly right — especially point 3. Clarifying that savingsplans-compute et al. were always dash-form on the wire (and that any dash-free aliases there would be speculative future work, not a #85 backfill) would have been genuinely difficult to reconstruct from git log alone. Putting it at the canonical alias site is the correct call.

Triggering the incremental review now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim cristim merged commit a21c140 into feat/multicloud-web-frontend Apr 30, 2026
4 checks passed
@cristim cristim deleted the chore/normalize-savings-plans-identifier branch April 30, 2026 14:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/chore Maintenance / non-user-visible urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant