Skip to content

Commit 2049690

Browse files
committed
Allow partial results for get ec2 instance action
1 parent 803a9f6 commit 2049690

4 files changed

Lines changed: 340 additions & 31 deletions

File tree

packages/blocks/aws/src/lib/actions/ec2/ec2-get-instances-action.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getAwsAccountsMultiSelectDropdown,
1111
getCredentialsListFromAuth,
1212
getEc2Instances,
13+
getEc2InstancesWithPartialResults,
1314
groupARNsByRegion,
1415
parseArn,
1516
} from '@openops/common';
@@ -55,6 +56,13 @@ export const ec2GetInstancesAction = createAction({
5556
}),
5657
...filterTagsProperties(),
5758
dryRun: dryRunCheckBox(),
59+
allowPartialResults: Property.Checkbox({
60+
displayName: 'Allow partial results',
61+
description:
62+
'When enabled, the step returns { results, failedRegions } and continues when some regions fail.',
63+
required: false,
64+
defaultValue: false,
65+
}),
5866
},
5967
async run(context) {
6068
try {
@@ -65,13 +73,72 @@ export const ec2GetInstancesAction = createAction({
6573
tags,
6674
condition,
6775
dryRun,
76+
allowPartialResults,
6877
} = context.propsValue;
6978
const filters: Filter[] = getFilters(context);
7079
const credentials = await getCredentialsListFromAuth(
7180
context.auth,
7281
accounts['accounts'],
7382
);
7483

84+
const partial = allowPartialResults === true;
85+
86+
if (partial) {
87+
const partialPromises = [];
88+
if (filterByARNs) {
89+
const arns = convertToStringArrayWithValidation(
90+
filterProperty['arns'] as unknown as string[],
91+
'Invalid input for ARNs: input should be a single string or an array of strings',
92+
);
93+
const groupedARNs = groupARNsByRegion(arns);
94+
95+
for (const region in groupedARNs) {
96+
const arnsForRegion = groupedARNs[region];
97+
const instanceIdFilter = {
98+
Name: 'instance-id',
99+
Values: arnsForRegion.map((arn) => parseArn(arn).resourceId),
100+
};
101+
partialPromises.push(
102+
...credentials.map((creds) =>
103+
getEc2InstancesWithPartialResults(
104+
creds,
105+
[region] as [string, ...string[]],
106+
dryRun,
107+
[...filters, instanceIdFilter],
108+
),
109+
),
110+
);
111+
}
112+
} else {
113+
const regions = convertToStringArrayWithValidation(
114+
filterProperty['regions'],
115+
'Invalid input for regions: input should be a single string or an array of strings',
116+
);
117+
partialPromises.push(
118+
...credentials.map((creds) =>
119+
getEc2InstancesWithPartialResults(
120+
creds,
121+
regions as [string, ...string[]],
122+
dryRun,
123+
filters,
124+
),
125+
),
126+
);
127+
}
128+
129+
const partialOutcomes = await Promise.all(partialPromises);
130+
let instances = partialOutcomes.flatMap((o) => o.results);
131+
const failedRegions = partialOutcomes.flatMap((o) => o.failedRegions);
132+
133+
if (tags?.length) {
134+
instances = instances.filter((instance) =>
135+
filterTags((instance.Tags as AwsTag[]) ?? [], tags, condition),
136+
);
137+
}
138+
139+
return { results: instances, failedRegions };
140+
}
141+
75142
const promises: any[] = [];
76143
if (filterByARNs) {
77144
const arns = convertToStringArrayWithValidation(
@@ -87,9 +154,9 @@ export const ec2GetInstancesAction = createAction({
87154
Values: arnsForRegion.map((arn) => parseArn(arn).resourceId),
88155
};
89156
promises.push(
90-
...credentials.map((credentials) =>
157+
...credentials.map((creds) =>
91158
getEc2Instances(
92-
credentials,
159+
creds,
93160
[region] as [string, ...string[]],
94161
dryRun,
95162
[...filters, instanceIdFilter],
@@ -103,8 +170,8 @@ export const ec2GetInstancesAction = createAction({
103170
'Invalid input for regions: input should be a single string or an array of strings',
104171
);
105172
promises.push(
106-
...credentials.map((credentials) =>
107-
getEc2Instances(credentials, regions, dryRun, filters),
173+
...credentials.map((creds) =>
174+
getEc2Instances(creds, regions, dryRun, filters),
108175
),
109176
);
110177
}

packages/blocks/aws/test/ec2/ec2-get-instances-action.test.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const openopsCommonMock = {
22
...jest.requireActual('@openops/common'),
33
getCredentialsListFromAuth: jest.fn(),
44
getEc2Instances: jest.fn(),
5+
getEc2InstancesWithPartialResults: jest.fn(),
56
dryRunCheckBox: jest.fn().mockReturnValue({
67
required: false,
78
defaultValue: false,
@@ -52,7 +53,7 @@ describe('ec2GetInstancesAction', () => {
5253
};
5354

5455
test('should create action with input regions property', () => {
55-
expect(Object.keys(ec2GetInstancesAction.props).length).toBe(8);
56+
expect(Object.keys(ec2GetInstancesAction.props).length).toBe(9);
5657
expect(ec2GetInstancesAction.props).toMatchObject({
5758
accounts: {
5859
required: true,
@@ -79,6 +80,11 @@ describe('ec2GetInstancesAction', () => {
7980
defaultValue: false,
8081
type: 'CHECKBOX',
8182
},
83+
allowPartialResults: {
84+
required: false,
85+
defaultValue: false,
86+
type: 'CHECKBOX',
87+
},
8288
filterByARNs: {
8389
type: 'CHECKBOX',
8490
},
@@ -514,4 +520,79 @@ describe('ec2GetInstancesAction', () => {
514520
[{ Name: 'instance-id', Values: ['i-2', 'i-4'] }],
515521
);
516522
});
523+
524+
test('when allowPartialResults, uses partial helper and returns object shape', async () => {
525+
openopsCommonMock.getEc2InstancesWithPartialResults.mockResolvedValue({
526+
results: [{ instance_id: 'i-1' }],
527+
failedRegions: [
528+
{ region: 'eu-west-1', accountId: '111', error: 'AccessDenied' },
529+
],
530+
});
531+
532+
const context = {
533+
...jest.requireActual('@openops/blocks-framework'),
534+
auth: auth,
535+
propsValue: {
536+
filterProperty: { regions: ['us-east-1', 'eu-west-1'] },
537+
dryRun: false,
538+
accounts: {},
539+
allowPartialResults: true,
540+
},
541+
};
542+
543+
const result = (await ec2GetInstancesAction.run(context)) as any;
544+
545+
expect(result).toEqual({
546+
results: [{ instance_id: 'i-1' }],
547+
failedRegions: [
548+
{ region: 'eu-west-1', accountId: '111', error: 'AccessDenied' },
549+
],
550+
});
551+
expect(
552+
openopsCommonMock.getEc2InstancesWithPartialResults,
553+
).toHaveBeenCalledWith(
554+
'credentials',
555+
['us-east-1', 'eu-west-1'],
556+
false,
557+
[],
558+
);
559+
expect(openopsCommonMock.getEc2Instances).not.toHaveBeenCalled();
560+
});
561+
562+
test('when allowPartialResults, aggregates multiple credentials', async () => {
563+
openopsCommonMock.getCredentialsListFromAuth.mockResolvedValue([
564+
'cred-a',
565+
'cred-b',
566+
]);
567+
openopsCommonMock.getEc2InstancesWithPartialResults
568+
.mockResolvedValueOnce({
569+
results: [{ id: 'a' }],
570+
failedRegions: [],
571+
})
572+
.mockResolvedValueOnce({
573+
results: [{ id: 'b' }],
574+
failedRegions: [{ region: 'us-west-2', accountId: '2', error: 'e' }],
575+
});
576+
577+
const context = {
578+
...jest.requireActual('@openops/blocks-framework'),
579+
auth: auth,
580+
propsValue: {
581+
filterProperty: { regions: ['us-east-1'] },
582+
dryRun: true,
583+
accounts: {},
584+
allowPartialResults: true,
585+
},
586+
};
587+
588+
const result = (await ec2GetInstancesAction.run(context)) as any;
589+
590+
expect(result.results).toEqual([{ id: 'a' }, { id: 'b' }]);
591+
expect(result.failedRegions).toEqual([
592+
{ region: 'us-west-2', accountId: '2', error: 'e' },
593+
]);
594+
expect(
595+
openopsCommonMock.getEc2InstancesWithPartialResults,
596+
).toHaveBeenCalledTimes(2);
597+
});
517598
});

packages/openops/src/lib/aws/ec2/ec2-get-instances.ts

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,55 @@ import { getAwsClient } from '../get-client';
44
import { getAccountName } from '../organizations-common';
55
import { getAccountId } from '../sts-common';
66

7+
export type AwsPartialFetchFailedRegion = {
8+
region: string;
9+
accountId?: string;
10+
error: string;
11+
};
12+
13+
export type AwsPartialFetchResult<T = unknown> = {
14+
results: T[];
15+
failedRegions: AwsPartialFetchFailedRegion[];
16+
};
17+
18+
export function formatAwsPartialFetchError(error: unknown): string {
19+
if (error instanceof Error) {
20+
return error.message;
21+
}
22+
return String(error);
23+
}
24+
25+
async function describeInstancesInRegion(
26+
credentials: any,
27+
region: string,
28+
dryRun: boolean,
29+
filters: EC2.Filter[] | undefined,
30+
accountId: string,
31+
accountName?: string,
32+
): Promise<any[]> {
33+
const ec2 = getAwsClient(EC2.EC2, credentials, region) as EC2.EC2;
34+
35+
const command = new EC2.DescribeInstancesCommand({
36+
Filters: filters,
37+
DryRun: dryRun,
38+
});
39+
const { Reservations } = await ec2.send(command);
40+
41+
return (
42+
Reservations?.flatMap(
43+
(reservation) =>
44+
reservation.Instances?.map((instance) =>
45+
mapInstanceToOpenOpsEc2Instance(
46+
instance,
47+
region,
48+
accountId,
49+
accountName,
50+
),
51+
) || [],
52+
) || []
53+
);
54+
}
55+
756
export async function getEc2Instances(
857
credentials: any,
958
regions: [string, ...string[]],
@@ -13,36 +62,76 @@ export async function getEc2Instances(
1362
const accountId = await getAccountId(credentials, regions[0]);
1463
const accountName = await getAccountName(credentials, regions[0], accountId);
1564

16-
const fetchInstancesInRegion = async (region: string): Promise<any[]> => {
17-
const ec2 = getAwsClient(EC2.EC2, credentials, region) as EC2.EC2;
18-
19-
const command = new EC2.DescribeInstancesCommand({
20-
Filters: filters,
21-
DryRun: dryRun,
22-
});
23-
const { Reservations } = await ec2.send(command);
24-
25-
return (
26-
Reservations?.flatMap(
27-
(reservation) =>
28-
reservation.Instances?.map((instance) =>
29-
mapInstanceToOpenOpsEc2Instance(
30-
instance,
31-
region,
32-
accountId,
33-
accountName,
34-
),
35-
) || [],
36-
) || []
37-
);
38-
};
39-
4065
const instancesFromAllRegions = await Promise.all(
41-
regions.map(fetchInstancesInRegion),
66+
regions.map((region) =>
67+
describeInstancesInRegion(
68+
credentials,
69+
region,
70+
dryRun,
71+
filters,
72+
accountId,
73+
accountName,
74+
),
75+
),
4276
);
4377
return instancesFromAllRegions.flat();
4478
}
4579

80+
export async function getEc2InstancesWithPartialResults(
81+
credentials: any,
82+
regions: [string, ...string[]],
83+
dryRun: boolean,
84+
filters?: EC2.Filter[],
85+
): Promise<AwsPartialFetchResult<any>> {
86+
let accountId: string;
87+
let accountName: string | undefined;
88+
89+
try {
90+
accountId = await getAccountId(credentials, regions[0]);
91+
accountName = await getAccountName(credentials, regions[0], accountId);
92+
} catch (error) {
93+
const message = formatAwsPartialFetchError(error);
94+
return {
95+
results: [],
96+
failedRegions: regions.map((region) => ({
97+
region,
98+
error: message,
99+
})),
100+
};
101+
}
102+
103+
const settled = await Promise.allSettled(
104+
regions.map((region) =>
105+
describeInstancesInRegion(
106+
credentials,
107+
region,
108+
dryRun,
109+
filters,
110+
accountId,
111+
accountName,
112+
),
113+
),
114+
);
115+
116+
const results: any[] = [];
117+
const failedRegions: AwsPartialFetchFailedRegion[] = [];
118+
119+
settled.forEach((outcome, index) => {
120+
const region = regions[index];
121+
if (outcome.status === 'fulfilled') {
122+
results.push(...outcome.value);
123+
} else {
124+
failedRegions.push({
125+
region,
126+
accountId,
127+
error: formatAwsPartialFetchError(outcome.reason),
128+
});
129+
}
130+
});
131+
132+
return { results, failedRegions };
133+
}
134+
46135
function mapInstanceToOpenOpsEc2Instance(
47136
instance: EC2.Instance,
48137
region: string,

0 commit comments

Comments
 (0)