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
146 changes: 146 additions & 0 deletions frontend/src/__tests__/riexchange-account-1692.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Issue #1692: the RI Exchange table and the Exchange dialog name the cloud
* account the RIs belong to, so an operator can confirm the target before
* quoting or executing an exchange.
*/

jest.mock('../api', () => ({
listConvertibleRIs: jest.fn(),
listAccountsMinimal: jest.fn(),
listExchangeableAzureRIs: jest.fn(),
getRIUtilization: jest.fn(),
getReshapeRecommendations: jest.fn(),
getRIExchangeHistory: jest.fn(),
listTargetOfferings: jest.fn(),
}));

jest.mock('../navigation', () => ({
switchTab: jest.fn(),
switchSettingsSubTab: jest.fn(),
}));

jest.mock('../state', () => ({
subscribeProvider: jest.fn(() => () => undefined),
subscribeAccount: jest.fn(() => () => undefined),
getCurrentProvider: jest.fn(() => 'aws'),
getCurrentAccountIDs: jest.fn(() => []),
getCurrentUser: jest.fn(() => ({ id: 'u', email: 'u@example.com', groups: ['00000000-0000-5000-8000-000000000001'] })),
getRiExchangeColumnFilters: jest.fn(() => ({})),
setRiExchangeColumnFilter: jest.fn(),
clearAllRiExchangeColumnFilters: jest.fn(),
getActiveRiColumnFilters: jest.fn(() => ({})),
setActiveRiColumnFilter: jest.fn(),
clearAllActiveRiColumnFilters: jest.fn(),
}));

jest.mock('../permissions', () => ({
canAccess: jest.fn(() => true),
}));

import { loadRIExchange, openExchangeModal } from '../riexchange';
import * as api from '../api';
import { flushPromises } from './flush-promises';

const ri = (id: string, accountID?: string) => ({
reserved_instance_id: id,
instance_type: 't4g.nano',
availability_zone: '',
instance_count: 1,
start: '2026-05-22T00:00:00Z',
end: '2027-05-22T00:00:00Z',
offering_type: 'No Upfront',
fixed_price: 0,
usage_price: 0,
state: 'active',
normalization_factor: 0.25,
...(accountID ? { account_id: accountID } : {}),
});

const hostAccount = { id: 'uuid-host', name: 'CUDly host (123456789012)', external_id: '123456789012', provider: 'aws' };
const plainAccount = { id: 'uuid-plain', name: 'Payer', external_id: '210987654321', provider: 'aws' };

describe('RI Exchange account label (issue #1692)', () => {
let instancesEl: HTMLDivElement;

beforeEach(() => {
document.body.replaceChildren();
instancesEl = document.createElement('div');
instancesEl.id = 'ri-exchange-instances-list';
const recsEl = document.createElement('div');
recsEl.id = 'ri-exchange-recommendations-list';
const historyEl = document.createElement('div');
historyEl.id = 'ri-exchange-history-list';
const modal = document.createElement('div');
modal.id = 'ri-exchange-modal';
const content = document.createElement('div');
content.className = 'modal-content';
modal.appendChild(content);
document.body.append(instancesEl, recsEl, historyEl, modal);

(api.getRIUtilization as jest.Mock).mockResolvedValue([]);
(api.getRIExchangeHistory as jest.Mock).mockResolvedValue([]);
(api.getReshapeRecommendations as jest.Mock).mockResolvedValue({ recommendations: [], recs_staleness: '', recs_collected_at: null });
(api.listTargetOfferings as jest.Mock).mockResolvedValue([]);
(api.listAccountsMinimal as jest.Mock).mockResolvedValue([hostAccount, plainAccount]);
});

const accountLine = (): string =>
instancesEl.querySelector('#ri-exchange-account-label')?.textContent?.trim() ?? '';

it('names the account once, without repeating an ID already in its name', async () => {
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([ri('ri-1', '123456789012'), ri('ri-2', '123456789012')]);

await loadRIExchange();
await flushPromises();

expect(accountLine()).toBe('Account: CUDly host (123456789012)');
});

it('adds the account ID when the name does not contain it', async () => {
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([ri('ri-1', '210987654321')]);

await loadRIExchange();
await flushPromises();

expect(accountLine()).toBe('Account: Payer (210987654321)');
});

it('falls back to the account ID when the account is not registered', async () => {
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([ri('ri-1', '555555555555')]);

await loadRIExchange();
await flushPromises();

expect(accountLine()).toBe('Account: AWS account 555555555555');
});

it('keeps the ID label when the account names cannot be loaded', async () => {
(api.listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('forbidden'));
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([ri('ri-1', '123456789012')]);

await loadRIExchange();
await flushPromises();

expect(accountLine()).toBe('Account: AWS account 123456789012');
});

it('says when the account could not be determined', async () => {
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([ri('ri-1')]);

await loadRIExchange();
await flushPromises();

expect(accountLine()).toBe('Account: could not be determined');
});

it('shows the account in the Exchange dialog', async () => {
(api.listConvertibleRIs as jest.Mock).mockResolvedValue([ri('ri-1', '123456789012')]);
await loadRIExchange();
await flushPromises();

openExchangeModal('ri-1', 1);

const dialogAccount = document.querySelector('#ri-exchange-modal #modal-exchange-account')?.textContent?.trim();
expect(dialogAccount).toBe('Account: CUDly host (123456789012)');
});
});
5 changes: 4 additions & 1 deletion frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,7 +1396,10 @@ describe('RI Exchange global filter scoping (issue #871)', () => {

it('forwards undefined and skips the account lookup when no account is selected', async () => {
await loadRIExchange();
expect(api.listAccountsMinimal).not.toHaveBeenCalled();
// No scope lookup (an unfiltered listAccountsMinimal()); the only call is
// the AWS account-name lookup for the account label (issue #1692).
expect(api.listAccountsMinimal).not.toHaveBeenCalledWith();
expect(api.listAccountsMinimal).toHaveBeenCalledWith({ provider: 'aws' });
expect(api.listConvertibleRIs).toHaveBeenCalledWith(undefined);
});

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,9 @@ export interface ConvertibleRI {
usage_price: number;
state: string;
normalization_factor: number;
// The AWS account the RI belongs to; absent when the API could not
// resolve it (issue #1692).
account_id?: string;
}

// ExchangeableAzureRI is one Azure VM reservation eligible for the
Expand Down
35 changes: 34 additions & 1 deletion frontend/src/riexchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ let currentRIs: ConvertibleRI[] = [];
let currentRIsLoaded = false;
let currentUtilization: Map<string, RIUtilization> = new Map();
let currentRecommendations: ReshapeRecommendation[] = [];
// Registered account names by AWS account ID, for the account label (#1692).
let riAccountNames: Map<string, string> = new Map();
const exchangePaymentPattern = /^\d+(?:\.\d+)?$/;

// Generation counter to prevent stale utilization data from overwriting fresh data
Expand Down Expand Up @@ -320,7 +322,7 @@ async function loadConvertibleRIs(accountID?: string, generation: number = scope
try {
// issue #871: scope to the selected account so the page honours the
// Main Header global filter.
const ris = await api.listConvertibleRIs(accountID);
const [ris] = await Promise.all([api.listConvertibleRIs(accountID), loadRIAccountNames()]);
if (generation !== scopeGeneration) return;
currentRIs = ris;
currentRIsLoaded = true;
Expand Down Expand Up @@ -371,6 +373,25 @@ async function loadExchangeableAzureRIs(accountID: string | undefined, generatio
}
}

// Best effort: without names the label falls back to the account ID.
async function loadRIAccountNames(): Promise<void> {
try {
const accounts = (await api.listAccountsMinimal({ provider: 'aws' })) ?? [];
riAccountNames = new Map(accounts.map(a => [a.external_id, a.name]));
} catch (error) {
console.warn('RI Exchange: could not load account names:', error);
riAccountNames = new Map();
}
}

// "CUDly host (123456789012)": the ID is added only when the name lacks it.
function riAccountLabel(accountID: string | undefined): string {
if (!accountID) return 'could not be determined';
const name = riAccountNames.get(accountID);
if (!name) return `AWS account ${accountID}`;
return name.includes(accountID) ? name : `${name} (${accountID})`;
}

async function loadUtilization(generation: number): Promise<void> {
try {
const utilization = await api.getRIUtilization();
Expand Down Expand Up @@ -416,7 +437,9 @@ function renderRIsTable(container: HTMLElement, accountID?: string): void {
return `<button type="button" class="column-filter-btn${active}" data-column="${column}" aria-haspopup="dialog" aria-expanded="false" aria-label="${escapeHtml(label)}" title="${escapeHtml(label)}">${iconSvg('filter')}</button>`;
};

const accountLabels = [...new Set(currentRIs.map(r => riAccountLabel(r.account_id)))];
container.innerHTML = `
<p class="setting-hint" id="ri-exchange-account-label">Account: ${escapeHtml(accountLabels.join(', '))}</p>
<table class="table-compact${canExchange ? ' table-sticky-end' : ''}">
<thead>
<tr>
Expand Down Expand Up @@ -1206,6 +1229,16 @@ export function openExchangeModal(riId: string, count: number, suggestedTargetTy
riRow.appendChild(riLabel);
content.appendChild(riRow);

// #1692: name the account before anything is quoted or executed.
const sourceRI = currentRIs.find(r => r.reserved_instance_id === riId);
if (sourceRI) {
const accountRow = document.createElement('div');
accountRow.className = 'setting-row';
accountRow.id = 'modal-exchange-account';
accountRow.textContent = `Account: ${riAccountLabel(sourceRI.account_id)}`;
content.appendChild(accountRow);
}

// Targets container: one or more rows, each with offering picker +
// count. Users click "+ Add target" to split a source RI across
// multiple target shapes in a single atomic AWS exchange.
Expand Down
58 changes: 37 additions & 21 deletions internal/api/handler_ri_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -1485,25 +1485,28 @@ func (h *Handler) resolveReshapeCloudAccountID(ctx context.Context) (string, err
return h.resolveAWSCloudAccountID(ctx)
}

// checkListRIsAccountIDParam enforces the ?account_id= chip filter for
// listConvertibleRIs. Returns (true, nil) when the running AWS account matches
// the requested account (or when no account_id param is given), (false, nil)
// when it does not match (caller should return an empty list), and
// (false, err) on STS failure.
func (h *Handler) checkListRIsAccountIDParam(ctx context.Context, params map[string]string) (bool, error) {
accountID := params["account_id"]
if accountID == "" {
return true, nil
}
// listRIsAccount resolves the AWS account the convertible RIs are read from
// (the deployment's ambient credentials, via STS) and applies the ?account_id=
// chip filter (issue #871). With a filter, an STS failure fails closed and a
// different account is out of scope. Without one the account only labels the
// rows (#1692), so a failure is logged and the label is left empty.
func (h *Handler) listRIsAccount(ctx context.Context, requested string) (accountID string, inScope bool, err error) {
resolve := h.resolveAWSAccountID
if h.riInstancesAccountResolver != nil {
resolve = h.riInstancesAccountResolver
}
runningAccountID, err := resolve(ctx)
if err != nil {
return false, fmt.Errorf("failed to resolve running AWS account for RI scope: %w", err)
running, resolveErr := resolve(ctx)
if requested == "" {
if resolveErr != nil {
logging.Warnf("listConvertibleRIs: could not resolve the running AWS account: %v", resolveErr)
return "", true, nil
}
return running, true, nil
}
return runningAccountID == accountID, nil
if resolveErr != nil {
return "", false, fmt.Errorf("failed to resolve running AWS account for RI scope: %w", resolveErr)
}
return running, running == requested, nil
}

// listConvertibleRIs returns all active convertible Reserved Instances for
Expand All @@ -1528,13 +1531,14 @@ func (h *Handler) listConvertibleRIs(ctx context.Context, req *events.LambdaFunc
if inScope, sErr := h.reshapeCloudAccountInScope(ctx, session); sErr != nil {
return nil, sErr
} else if !inScope {
return &ConvertibleRIsResponse{Instances: []ec2ConvertibleRI{}}, nil
return &ConvertibleRIsResponse{Instances: []convertibleRIWithAccount{}}, nil
}

if inScope, sErr := h.checkListRIsAccountIDParam(ctx, req.QueryStringParameters); sErr != nil {
return nil, sErr
runningAccountID, inScope, err := h.listRIsAccount(ctx, req.QueryStringParameters["account_id"])
if err != nil {
return nil, err
} else if !inScope {
return &ConvertibleRIsResponse{Instances: []ec2ConvertibleRI{}}, nil
return &ConvertibleRIsResponse{Instances: []convertibleRIWithAccount{}}, nil
}

region := req.QueryStringParameters["region"]
Expand All @@ -1543,13 +1547,17 @@ func (h *Handler) listConvertibleRIs(ctx context.Context, req *events.LambdaFunc
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}

ec2Client := newReshapeEC2Client(cfg)
ec2Client := h.buildReshapeEC2Client(cfg)
instances, err := ec2Client.ListConvertibleReservedInstances(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list convertible RIs: %w", err)
}

return &ConvertibleRIsResponse{Instances: instances}, nil
out := make([]convertibleRIWithAccount, len(instances))
for i := range instances {
out[i] = convertibleRIWithAccount{ec2ConvertibleRI: instances[i], AccountID: runningAccountID}
}
return &ConvertibleRIsResponse{Instances: out}, nil
}

// getRIUtilization returns per-RI utilization from Cost Explorer.
Expand Down Expand Up @@ -2108,7 +2116,15 @@ func mapAWSExchangeError(opMsg string, err error) error {

// ConvertibleRIsResponse holds the list of convertible RIs.
type ConvertibleRIsResponse struct {
Instances []ec2ConvertibleRI `json:"instances"`
Instances []convertibleRIWithAccount `json:"instances"`
}

// convertibleRIWithAccount names the AWS account an RI belongs to, so the UI
// can show it before an exchange (#1692). AccountID is empty when the running
// account could not be resolved.
type convertibleRIWithAccount struct {
ec2ConvertibleRI
AccountID string `json:"account_id,omitempty"`
}

// ExchangeableAzureRIsResponse holds the list of Azure VM reservations that
Expand Down
Loading
Loading