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
106 changes: 106 additions & 0 deletions frontend/src/__tests__/commitmentOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Tests for commitmentOptions module
*/
import {
fetchAndPopulateCommitmentOptions,
getCommitmentConfig,
isValidCombination,
getValidPaymentOptions,
Expand Down Expand Up @@ -707,4 +708,109 @@ describe('commitmentOptions', () => {
expect(configWithInvalid.invalidCombinations).toHaveLength(1);
});
});

describe('fetchAndPopulateCommitmentOptions', () => {
// These tests mutate the shared commitmentConfigs module state via the
// function under test. Restore a known-good overlay at the end of each
// test by re-populating with the hardcoded fallback shape (no supported
// combos → full invalidCombinations → close enough to the starting
// state for subsequent tests to see consistent results).
const mockOk = (aws: Record<string, Array<{ term: number; payment: string }>>) =>
jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: 'ok', aws })
} as Response);

afterEach(() => {
// Reset RDS back to hardcoded — other tests in this file assert RDS
// retains `{term:3, payment:'no-upfront'}` as invalid.
return fetchAndPopulateCommitmentOptions(
mockOk({ rds: [
{ term: 1, payment: 'all-upfront' },
{ term: 1, payment: 'partial-upfront' },
{ term: 1, payment: 'no-upfront' },
{ term: 3, payment: 'all-upfront' },
{ term: 3, payment: 'partial-upfront' },
]})
);
});

it('overlays server-supplied combos onto AWS services', async () => {
// Server says RDS supports ONLY 1yr all-upfront — everything else is
// invalid. Overlay should reflect that exactly.
const fetchMock = mockOk({
rds: [{ term: 1, payment: 'all-upfront' }],
});

await fetchAndPopulateCommitmentOptions(fetchMock);

expect(fetchMock).toHaveBeenCalledWith('/api/commitment-options');
expect(isValidCombination('aws', 'rds', 1, 'all-upfront')).toBe(true);
expect(isValidCombination('aws', 'rds', 1, 'partial-upfront')).toBe(false);
expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(false);
});

it('leaves hardcoded rules intact on status:unavailable', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ status: 'unavailable' })
} as Response);

await fetchAndPopulateCommitmentOptions(fetchMock);

// RDS hardcoded rule: 3yr no-upfront invalid, others valid.
expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(false);
expect(isValidCombination('aws', 'rds', 1, 'all-upfront')).toBe(true);
});

it('leaves hardcoded rules intact on HTTP error', async () => {
const fetchMock = jest.fn().mockResolvedValue({ ok: false } as Response);

await fetchAndPopulateCommitmentOptions(fetchMock);

expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(false);
expect(isValidCombination('aws', 'rds', 1, 'all-upfront')).toBe(true);
});

it('leaves hardcoded rules intact on network error', async () => {
const fetchMock = jest.fn().mockRejectedValue(new Error('network down'));

await fetchAndPopulateCommitmentOptions(fetchMock);

expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(false);
});

it('clears invalidCombinations when server reports full support', async () => {
const allCombos = [1, 3].flatMap(term =>
['all-upfront', 'partial-upfront', 'no-upfront'].map(payment => ({ term, payment }))
);
const fetchMock = mockOk({ rds: allCombos });

await fetchAndPopulateCommitmentOptions(fetchMock);

// Every combo now valid — previously 3yr no-upfront was blocked.
expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(true);
expect(isValidCombination('aws', 'rds', 1, 'partial-upfront')).toBe(true);
});

it('re-widens after a subsequent overlay reports broader support', async () => {
// Settings tab is re-entered after the server probe completes with
// a fuller combo set. The overlay must diff against the canonical
// STANDARD_TERMS × AWS_PAYMENTS product, not against the narrow
// result of the first call, or the UI stays stuck on the
// intersection and never widens.
const narrow = mockOk({ rds: [{ term: 1, payment: 'all-upfront' }] });
await fetchAndPopulateCommitmentOptions(narrow);
expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(false);

const allCombos = [1, 3].flatMap(term =>
['all-upfront', 'partial-upfront', 'no-upfront'].map(payment => ({ term, payment }))
);
const wide = mockOk({ rds: allCombos });
await fetchAndPopulateCommitmentOptions(wide);

expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(true);
expect(isValidCombination('aws', 'rds', 1, 'partial-upfront')).toBe(true);
});
});
});
63 changes: 63 additions & 0 deletions frontend/src/commitmentOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,69 @@ export function getPaymentLabel(value: string): string {
return payment?.label ?? value;
}

interface ServerCommitmentOptions {
status: 'ok' | 'unavailable';
aws?: Record<string, Array<{ term: number; payment: string }>>;
}

// FetchLike is a subset of fetch's signature that tests can stub without
// touching the global. Defaults to `fetch` when the caller passes nothing.
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;

/**
* Fetch the dynamically-probed AWS commitment options from the backend and
* overlay them onto `commitmentConfigs.aws`. Server-side data always wins —
* we trust live AWS offerings over our hardcoded list. On any failure
* (network error, non-200, {status:"unavailable"}) the hardcoded rules stay
* intact so the UI still gates correctly.
*
* Idempotent: calling twice with the same data is a no-op. The Settings
* page awaits this before syncing per-service constraints so the first
* render reflects the server's rules, not the fallback's.
*/
export async function fetchAndPopulateCommitmentOptions(fetchFn?: FetchLike): Promise<void> {
const f: FetchLike = fetchFn ?? ((input, init) => fetch(input, init));

let body: ServerCommitmentOptions;
try {
const resp = await f('/api/commitment-options');
if (!resp.ok) return;
body = (await resp.json()) as ServerCommitmentOptions;
} catch {
// Network/parse errors: silently fall back to hardcoded rules. The
// frontend still works; we just miss the dynamic overlay.
return;
}

if (body.status !== 'ok' || !body.aws) return;

const awsConfigs = commitmentConfigs.aws ?? (commitmentConfigs.aws = {});
for (const [service, supportedCombos] of Object.entries(body.aws)) {
// Always diff against the canonical STANDARD_TERMS × AWS_PAYMENTS
// product, not against the existing (possibly-narrowed-from-a-prior-
// overlay) entry. Otherwise, when the server widens its supported set
// on a later fetch, we'd be computing the intersection against a
// stale narrow base and the UI would stay restrictive.
const supportedKeys = new Set(
supportedCombos.map(c => `${c.term}:${c.payment}`)
);
const invalid: Array<{ term: number; payment: string }> = [];
for (const term of STANDARD_TERMS) {
for (const payment of AWS_PAYMENTS) {
if (!supportedKeys.has(`${term.value}:${payment.value}`)) {
invalid.push({ term: term.value, payment: payment.value });
}
}
}

awsConfigs[service] = {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS,
invalidCombinations: invalid.length > 0 ? invalid : undefined,
};
}
}

/**
* Map legacy AWS payment values to display labels
*/
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/

import * as api from './api';
import { fetchAndPopulateCommitmentOptions } from './commitmentOptions';
import { initFederationPanel } from './federation';
import { confirmDialog } from './confirmDialog';
import { reflectDirtyState } from './settings-subnav';
Expand Down Expand Up @@ -1204,6 +1205,11 @@ export async function loadGlobalSettings(): Promise<void> {
if (formEl) formEl.classList.add('hidden');
if (errorEl) errorEl.classList.add('hidden');

// Overlay dynamically-probed AWS commitment rules before we render the
// form, so the first paint already respects server data. Failures fall
// back to hardcoded rules silently — we never block Settings on this.
await fetchAndPopulateCommitmentOptions();

try {
const data = await api.getConfig();

Expand Down
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.26.2
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.61.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ec2 v1.251.2
github.com/aws/aws-sdk-go-v2/service/elasticache v1.50.3 // indirect
github.com/aws/aws-sdk-go-v2/service/memorydb v1.31.4 // indirect
github.com/aws/aws-sdk-go-v2/service/opensearch v1.52.3 // indirect
github.com/aws/aws-sdk-go-v2/service/elasticache v1.50.3
github.com/aws/aws-sdk-go-v2/service/memorydb v1.31.4
github.com/aws/aws-sdk-go-v2/service/opensearch v1.52.3
github.com/aws/aws-sdk-go-v2/service/rds v1.97.3
github.com/aws/aws-sdk-go-v2/service/redshift v1.58.3 // indirect
github.com/aws/aws-sdk-go-v2/service/redshift v1.58.3
github.com/spf13/cobra v1.8.0
github.com/stretchr/testify v1.11.1
)
Expand Down
7 changes: 7 additions & 0 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ type Handler struct {
// fields stay nil.
reshapeEC2Factory func(aws.Config) reshapeEC2Client
reshapeRecsFactory func(aws.Config) reshapeRecsClient

// commitmentOpts discovers which AWS (term, payment) combinations
// each service actually sells and validates saves against that data.
// Nil is valid: the endpoint returns unavailable and save-side
// validation no-ops, deferring to the frontend's hardcoded rules.
commitmentOpts CommitmentOptsInterface
}

// getRIUtilizationCache returns the Postgres-backed TTL cache for Cost
Expand Down Expand Up @@ -111,6 +117,7 @@ func NewHandler(cfg HandlerConfig) *Handler {
analyticsCollector: cfg.AnalyticsCollector,
signer: cfg.OIDCSigner,
issuerURL: cfg.OIDCIssuerURL,
commitmentOpts: cfg.CommitmentOpts,
}

// Pre-load API key (with a 5s timeout to avoid stalling cold-start indefinitely)
Expand Down
68 changes: 68 additions & 0 deletions internal/api/handler_commitment_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Package api provides the HTTP API handlers for the CUDly dashboard.
package api

import (
"context"
"errors"

"github.com/LeanerCloud/CUDly/internal/commitmentopts"
"github.com/LeanerCloud/CUDly/pkg/logging"
)

// commitmentOptionsResponse is the JSON shape returned by
// GET /api/commitment-options. On success, AWS carries the probed combos
// keyed by service (rds, elasticache, ...). Azure and GCP are deliberately
// omitted — those commitment rules stay hardcoded in the frontend because
// their APIs don't expose a comparable probe.
type commitmentOptionsResponse struct {
Status string `json:"status"`
AWS map[string][]commitmentOptionCombo `json:"aws,omitempty"`
}

// commitmentOptionCombo is one (term, payment) tuple as the frontend
// consumes it. Dropping Provider/Service from the persisted Combo shape
// keeps the wire payload compact.
type commitmentOptionCombo struct {
Term int `json:"term"`
Payment string `json:"payment"`
}

// getCommitmentOptions returns the dynamically-probed AWS commitment
// options. On any error or when the probe hasn't run yet, it returns
// {"status":"unavailable"} (200, not a 5xx) so the frontend can fall
// back to its hardcoded defaults without tripping the generic
// error-toast path.
func (h *Handler) getCommitmentOptions(ctx context.Context) (*commitmentOptionsResponse, error) {
if h.commitmentOpts == nil {
return &commitmentOptionsResponse{Status: "unavailable"}, nil
}
opts, err := h.commitmentOpts.Get(ctx)
if err != nil {
// Any error collapses to "unavailable" so a transient DB blip
// or context cancellation doesn't break the Settings page
// overlay fetch. ErrNoData is the quiet path (no account
// connected / fresh install); anything else is logged so
// operators can still trace DB/connection issues.
if !errors.Is(err, commitmentopts.ErrNoData) {
logging.Warnf("commitmentopts handler: %v", err)
}
return &commitmentOptionsResponse{Status: "unavailable"}, nil
}

awsOpts := opts["aws"]
out := make(map[string][]commitmentOptionCombo, len(awsOpts))
for svc, combos := range awsOpts {
out[svc] = make([]commitmentOptionCombo, 0, len(combos))
for _, c := range combos {
out[svc] = append(out[svc], commitmentOptionCombo{Term: c.TermYears, Payment: c.Payment})
}
}
if len(out) == 0 {
// Probe ran but returned nothing for AWS (e.g. every probe got
// filtered out by normalization). Treat as unavailable so the
// frontend falls through to its hardcoded rules rather than
// rendering an empty constraint set.
return &commitmentOptionsResponse{Status: "unavailable"}, nil
}
return &commitmentOptionsResponse{Status: "ok", AWS: out}, nil
}
Loading