Skip to content

Commit ead1221

Browse files
authored
Add option resolution for Azure benchmark provider (#2161)
Fixes OPS-3948. ## Additional Notes What's `not` in the scope of this PR: - Registering the Azure provider - Creating an Azure benchmark
1 parent 080ebc5 commit ead1221

7 files changed

Lines changed: 424 additions & 3 deletions

File tree

packages/openops/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export * from './lib/openops-analytics/requests-helpers';
6464
export * from './lib/azure/auth';
6565
export * from './lib/azure/regions';
6666
export * from './lib/azure/subscription/get-subscription-dropdown';
67+
export * from './lib/azure/subscription/get-subscriptions';
6768

6869
export * from './lib/axios-wrapper';
6970
export * from './lib/cloud-cli-common';

packages/server/api/src/app/benchmark/provider-adapter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,10 @@ export type ProviderAdapter = {
4949
method: string,
5050
context: WizardContext,
5151
): Promise<BenchmarkWizardOption[]>;
52-
evaluateCondition(
52+
evaluateCondition?: (
5353
condition: string,
5454
context: WizardContext,
55-
): Promise<boolean>;
55+
) => Promise<boolean>;
5656
};
5757

5858
const providers = new Map<string, ProviderAdapter>();
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import {
2+
authenticateUserWithAzure,
3+
getAzureRegionsList,
4+
getAzureSubscriptionsList,
5+
} from '@openops/common';
6+
import { logger } from '@openops/server-shared';
7+
import {
8+
BenchmarkWizardOption,
9+
CustomAuthConnectionValue,
10+
REGION_IMAGE_LOGO_URL,
11+
} from '@openops/shared';
12+
import { appConnectionService } from '../../../app-connection/app-connection-service/app-connection-service';
13+
import {
14+
getAuthProviderLogoUrl,
15+
listConnections,
16+
} from '../../common-resolvers';
17+
import { throwValidationError } from '../../errors';
18+
import type { WizardContext } from '../../provider-adapter';
19+
20+
export async function resolveOptions(
21+
method: string,
22+
context: WizardContext,
23+
): Promise<BenchmarkWizardOption[]> {
24+
switch (method) {
25+
case 'listConnections':
26+
return listConnections(context);
27+
case 'getSubscriptionsList':
28+
return getSubscriptionsList(context);
29+
case 'getRegionsList':
30+
return getAzureRegionsList().map((region) => ({
31+
...region,
32+
imageLogoUrl: REGION_IMAGE_LOGO_URL,
33+
}));
34+
default:
35+
throwValidationError(`Unknown Azure wizard option method: ${method}`);
36+
}
37+
}
38+
39+
async function getSubscriptionsList(
40+
context: WizardContext,
41+
): Promise<BenchmarkWizardOption[]> {
42+
const connectionId = context.benchmarkConfiguration?.connection?.[0];
43+
if (!connectionId) {
44+
throwValidationError('Connection must be selected to list subscriptions');
45+
}
46+
47+
const connection = await appConnectionService.getOneOrThrow({
48+
id: connectionId,
49+
projectId: context.projectId,
50+
});
51+
52+
const credentials = (connection.value as CustomAuthConnectionValue)?.props;
53+
54+
let subscriptions: { subscriptionId: string; displayName: string }[];
55+
try {
56+
const tokenResult = await authenticateUserWithAzure(credentials);
57+
subscriptions = await getAzureSubscriptionsList(tokenResult.access_token);
58+
} catch (error) {
59+
logger.warn('Failed to retrieve Azure subscriptions for benchmark wizard', {
60+
projectId: context.projectId,
61+
connectionId,
62+
error,
63+
});
64+
throwValidationError(
65+
'Unable to retrieve Azure subscriptions with the provided connection details.',
66+
);
67+
}
68+
69+
if (subscriptions.length === 0) {
70+
throwValidationError(
71+
'No Azure subscriptions were returned for this connection. Check access and tenant configuration.',
72+
);
73+
}
74+
75+
const imageLogoUrl = await getAuthProviderLogoUrl(
76+
connection.authProviderKey,
77+
context.projectId,
78+
);
79+
80+
return subscriptions.map((sub) => ({
81+
id: sub.subscriptionId,
82+
displayName: sub.displayName,
83+
...(imageLogoUrl && { imageLogoUrl }),
84+
}));
85+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import type { ProviderAdapter, WizardConfig } from '../../provider-adapter';
2+
import { resolveOptions } from './azure-option-resolver';
3+
import azureConfig from './azure.json';
4+
5+
export const azureProviderAdapter: ProviderAdapter = {
6+
config: azureConfig as WizardConfig,
7+
resolveOptions,
8+
};

packages/server/api/src/app/benchmark/wizard.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,13 @@ async function resolveNextStep(
4444
if (!nextStepDef.conditional) {
4545
return nextStepDef;
4646
}
47-
const conditionResult = await providerAdapter.evaluateCondition(
47+
const evaluateCondition = providerAdapter.evaluateCondition;
48+
if (!evaluateCondition) {
49+
throwValidationError(
50+
'Benchmark provider does not support conditional wizard steps',
51+
);
52+
}
53+
const conditionResult = await evaluateCondition(
4854
nextStepDef.conditional.when,
4955
context,
5056
);
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { ErrorCode, REGION_IMAGE_LOGO_URL } from '@openops/shared';
2+
3+
import { resolveOptions } from '../../../../../src/app/benchmark/providers/azure/azure-option-resolver';
4+
5+
const mockListConnections = jest.fn();
6+
const mockAuthenticateUserWithAzure = jest.fn();
7+
const mockGetAzureSubscriptionsList = jest.fn();
8+
const mockGetAzureRegionsList = jest.fn();
9+
const mockGetAuthProviderLogoUrl = jest.fn();
10+
11+
jest.mock('../../../../../src/app/benchmark/common-resolvers', () => ({
12+
...jest.requireActual('../../../../../src/app/benchmark/common-resolvers'),
13+
listConnections: (
14+
...args: unknown[]
15+
): ReturnType<typeof mockListConnections> => mockListConnections(...args),
16+
getAuthProviderLogoUrl: (
17+
...args: unknown[]
18+
): ReturnType<typeof mockGetAuthProviderLogoUrl> =>
19+
mockGetAuthProviderLogoUrl(...args),
20+
}));
21+
22+
jest.mock('@openops/common', () => ({
23+
...jest.requireActual('@openops/common'),
24+
authenticateUserWithAzure: (
25+
...args: unknown[]
26+
): ReturnType<typeof mockAuthenticateUserWithAzure> =>
27+
mockAuthenticateUserWithAzure(...args),
28+
getAzureSubscriptionsList: (
29+
...args: unknown[]
30+
): ReturnType<typeof mockGetAzureSubscriptionsList> =>
31+
mockGetAzureSubscriptionsList(...args),
32+
getAzureRegionsList: (
33+
...args: unknown[]
34+
): ReturnType<typeof mockGetAzureRegionsList> =>
35+
mockGetAzureRegionsList(...args),
36+
}));
37+
38+
const mockGetOneOrThrow = jest.fn();
39+
jest.mock(
40+
'../../../../../src/app/app-connection/app-connection-service/app-connection-service',
41+
() => ({
42+
appConnectionService: {
43+
getOneOrThrow: (
44+
...args: unknown[]
45+
): ReturnType<typeof mockGetOneOrThrow> => mockGetOneOrThrow(...args),
46+
},
47+
}),
48+
);
49+
50+
describe('resolveOptions (Azure)', () => {
51+
const projectId = 'project-123';
52+
const provider = 'azure';
53+
54+
beforeEach(() => {
55+
jest.clearAllMocks();
56+
});
57+
58+
it('delegates to listConnections and returns its result for listConnections', async () => {
59+
const options = [
60+
{
61+
id: 'conn-1',
62+
displayName: 'Connection 1',
63+
metadata: { authProviderKey: 'Azure' },
64+
},
65+
];
66+
mockListConnections.mockResolvedValue(options);
67+
68+
const result = await resolveOptions('listConnections', {
69+
projectId,
70+
provider,
71+
});
72+
73+
expect(mockListConnections).toHaveBeenCalledTimes(1);
74+
expect(mockListConnections).toHaveBeenCalledWith({ projectId, provider });
75+
expect(result).toEqual(options);
76+
});
77+
78+
it('throws when getSubscriptionsList is called without a selected connection', async () => {
79+
await expect(
80+
resolveOptions('getSubscriptionsList', {
81+
projectId,
82+
provider,
83+
}),
84+
).rejects.toThrow('Connection must be selected to list subscriptions');
85+
expect(mockGetOneOrThrow).not.toHaveBeenCalled();
86+
});
87+
88+
it('returns subscriptions for getSubscriptionsList', async () => {
89+
mockGetOneOrThrow.mockResolvedValue({
90+
authProviderKey: 'Azure',
91+
value: {
92+
type: 'CUSTOM_AUTH',
93+
props: {
94+
clientId: 'cid',
95+
clientSecret: 'secret',
96+
tenantId: 'tenant',
97+
},
98+
},
99+
});
100+
mockAuthenticateUserWithAzure.mockResolvedValue({
101+
access_token: 'token-1',
102+
});
103+
mockGetAzureSubscriptionsList.mockResolvedValue([
104+
{
105+
subscriptionId: 'sub-a',
106+
displayName: 'Sub A',
107+
},
108+
{
109+
subscriptionId: 'sub-b',
110+
displayName: 'Sub B',
111+
},
112+
]);
113+
mockGetAuthProviderLogoUrl.mockResolvedValue('/blocks/azure.svg');
114+
115+
const result = await resolveOptions('getSubscriptionsList', {
116+
projectId,
117+
provider,
118+
benchmarkConfiguration: { connection: ['conn-123'] },
119+
});
120+
121+
expect(mockGetOneOrThrow).toHaveBeenCalledWith({
122+
id: 'conn-123',
123+
projectId,
124+
});
125+
expect(mockAuthenticateUserWithAzure).toHaveBeenCalledWith({
126+
clientId: 'cid',
127+
clientSecret: 'secret',
128+
tenantId: 'tenant',
129+
});
130+
expect(mockGetAzureSubscriptionsList).toHaveBeenCalledWith('token-1');
131+
expect(mockGetAuthProviderLogoUrl).toHaveBeenCalledWith('Azure', projectId);
132+
expect(result).toEqual([
133+
{
134+
id: 'sub-a',
135+
displayName: 'Sub A',
136+
imageLogoUrl: '/blocks/azure.svg',
137+
},
138+
{
139+
id: 'sub-b',
140+
displayName: 'Sub B',
141+
imageLogoUrl: '/blocks/azure.svg',
142+
},
143+
]);
144+
});
145+
146+
it('omits imageLogoUrl for getSubscriptionsList when getAuthProviderLogoUrl returns undefined', async () => {
147+
mockGetOneOrThrow.mockResolvedValue({
148+
authProviderKey: 'Azure',
149+
value: {
150+
type: 'CUSTOM_AUTH',
151+
props: {
152+
clientId: 'cid',
153+
clientSecret: 'secret',
154+
tenantId: 'tenant',
155+
},
156+
},
157+
});
158+
mockAuthenticateUserWithAzure.mockResolvedValue({ access_token: 't' });
159+
mockGetAzureSubscriptionsList.mockResolvedValue([
160+
{ subscriptionId: 'sub-1', displayName: 'One' },
161+
]);
162+
mockGetAuthProviderLogoUrl.mockResolvedValue(undefined);
163+
164+
const result = await resolveOptions('getSubscriptionsList', {
165+
projectId,
166+
provider,
167+
benchmarkConfiguration: { connection: ['c1'] },
168+
});
169+
170+
expect(mockGetAuthProviderLogoUrl).toHaveBeenCalledWith('Azure', projectId);
171+
expect(result).toEqual([{ id: 'sub-1', displayName: 'One' }]);
172+
});
173+
174+
it('throws validation error when subscriptions list is empty', async () => {
175+
mockGetOneOrThrow.mockResolvedValue({
176+
authProviderKey: 'Azure',
177+
value: {
178+
type: 'CUSTOM_AUTH',
179+
props: {
180+
clientId: 'client_id',
181+
clientSecret: 'client_secret',
182+
tenantId: 'tenant',
183+
},
184+
},
185+
});
186+
mockAuthenticateUserWithAzure.mockResolvedValue({ access_token: 't' });
187+
mockGetAzureSubscriptionsList.mockResolvedValue([]);
188+
189+
const rejection = resolveOptions('getSubscriptionsList', {
190+
projectId,
191+
provider,
192+
benchmarkConfiguration: { connection: ['c1'] },
193+
});
194+
await expect(rejection).rejects.toMatchObject({
195+
error: { code: ErrorCode.VALIDATION },
196+
});
197+
await expect(rejection).rejects.toThrow(
198+
/No Azure subscriptions were returned for this connection/,
199+
);
200+
});
201+
202+
it('throws validation error when subscription listing fails', async () => {
203+
mockGetOneOrThrow.mockResolvedValue({
204+
authProviderKey: 'Azure',
205+
value: {
206+
type: 'CUSTOM_AUTH',
207+
props: {
208+
clientId: 'client_id',
209+
clientSecret: 'client_secret',
210+
tenantId: 'tenant',
211+
},
212+
},
213+
});
214+
mockAuthenticateUserWithAzure.mockRejectedValue(new Error('token failed'));
215+
216+
const rejection = resolveOptions('getSubscriptionsList', {
217+
projectId,
218+
provider,
219+
benchmarkConfiguration: { connection: ['c1'] },
220+
});
221+
await expect(rejection).rejects.toMatchObject({
222+
error: { code: ErrorCode.VALIDATION },
223+
});
224+
await expect(rejection).rejects.toThrow(
225+
/Unable to retrieve Azure subscriptions with the provided connection details/,
226+
);
227+
});
228+
229+
it('delegates to getAzureRegionsList and returns options with imageLogoUrl for getRegionsList', async () => {
230+
const regionsList = [
231+
{ id: 'eastus', displayName: 'East US' },
232+
{ id: 'westus2', displayName: 'West US 2' },
233+
];
234+
mockGetAzureRegionsList.mockReturnValue(regionsList);
235+
236+
const result = await resolveOptions('getRegionsList', {
237+
projectId,
238+
provider,
239+
});
240+
241+
expect(mockGetAzureRegionsList).toHaveBeenCalledTimes(1);
242+
expect(mockListConnections).not.toHaveBeenCalled();
243+
expect(result).toEqual([
244+
{
245+
id: 'eastus',
246+
displayName: 'East US',
247+
imageLogoUrl: REGION_IMAGE_LOGO_URL,
248+
},
249+
{
250+
id: 'westus2',
251+
displayName: 'West US 2',
252+
imageLogoUrl: REGION_IMAGE_LOGO_URL,
253+
},
254+
]);
255+
});
256+
257+
it('throws with method name in message for unknown method', async () => {
258+
await expect(
259+
resolveOptions('unknownMethod', { projectId, provider }),
260+
).rejects.toThrow('Unknown Azure wizard option method: unknownMethod');
261+
expect(mockListConnections).not.toHaveBeenCalled();
262+
});
263+
});

0 commit comments

Comments
 (0)