Skip to content

Commit 8eea493

Browse files
authored
Modify EC2 Get Instances action to allow partial results (#2216)
Part of OPS-4065. I will modify the remaining AWS Actions in the follow-up PRs. ## Additional Notes <img width="583" height="624" alt="Screenshot 2026-04-13 at 12 13 23 PM" src="https://github.com/user-attachments/assets/21dcb919-f272-4d53-a716-37bc1a00fbe5" />
1 parent 23ebcf3 commit 8eea493

4 files changed

Lines changed: 331 additions & 60 deletions

File tree

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

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getAwsAccountsMultiSelectDropdown,
1111
getCredentialsListFromAuth,
1212
getEc2Instances,
13+
getEc2InstancesAllowPartial,
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 partial results if the operation fails in some selected regions.',
63+
required: false,
64+
defaultValue: false,
65+
}),
5866
},
5967
async run(context) {
6068
try {
@@ -65,51 +73,57 @@ 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

75-
const promises: any[] = [];
76-
if (filterByARNs) {
77-
const arns = convertToStringArrayWithValidation(
78-
filterProperty['arns'] as unknown as string[],
79-
'Invalid input for ARNs: input should be a single string or an array of strings',
80-
);
81-
const groupedARNs = groupARNsByRegion(arns);
82-
83-
for (const region in groupedARNs) {
84-
const arnsForRegion = groupedARNs[region];
85-
const instanceIdFilter = {
86-
Name: 'instance-id',
87-
Values: arnsForRegion.map((arn) => parseArn(arn).resourceId),
88-
};
89-
promises.push(
90-
...credentials.map((credentials) =>
91-
getEc2Instances(
92-
credentials,
93-
[region] as [string, ...string[]],
94-
dryRun,
95-
[...filters, instanceIdFilter],
96-
),
84+
const partial = allowPartialResults === true;
85+
const batches = buildEc2GetInstancesBatches(
86+
filterByARNs,
87+
filterProperty,
88+
credentials,
89+
filters,
90+
);
91+
92+
if (partial) {
93+
const partialOutcomes = await Promise.all(
94+
batches.map((batch) =>
95+
getEc2InstancesAllowPartial(
96+
batch.creds,
97+
batch.regions,
98+
dryRun,
99+
batch.fetchFilters,
97100
),
98-
);
99-
}
100-
} else {
101-
const regions = convertToStringArrayWithValidation(
102-
filterProperty['regions'],
103-
'Invalid input for regions: input should be a single string or an array of strings',
104-
);
105-
promises.push(
106-
...credentials.map((credentials) =>
107-
getEc2Instances(credentials, regions, dryRun, filters),
108101
),
109102
);
103+
let instances = partialOutcomes.flatMap((o) => o.results);
104+
const failedRegions = partialOutcomes.flatMap((o) => o.failedRegions);
105+
106+
if (tags?.length) {
107+
instances = instances.filter((instance) =>
108+
filterTags((instance.Tags as AwsTag[]) ?? [], tags, condition),
109+
);
110+
}
111+
112+
return { results: instances, failedRegions };
110113
}
111114

112-
const instances = (await Promise.all(promises)).flat();
115+
const instances = (
116+
await Promise.all(
117+
batches.map((batch) =>
118+
getEc2Instances(
119+
batch.creds,
120+
batch.regions,
121+
dryRun,
122+
batch.fetchFilters,
123+
),
124+
),
125+
)
126+
).flat();
113127

114128
if (tags?.length) {
115129
return instances.filter((instance) =>
@@ -126,6 +140,58 @@ export const ec2GetInstancesAction = createAction({
126140
},
127141
});
128142

143+
type Ec2GetInstancesBatch = {
144+
creds: any;
145+
regions: [string, ...string[]];
146+
fetchFilters: Filter[];
147+
};
148+
149+
function buildEc2GetInstancesBatches(
150+
filterByARNs: boolean,
151+
filterProperty: Record<string, unknown>,
152+
credentials: any[],
153+
filters: Filter[],
154+
): Ec2GetInstancesBatch[] {
155+
const batches: Ec2GetInstancesBatch[] = [];
156+
157+
if (filterByARNs) {
158+
const arns = convertToStringArrayWithValidation(
159+
filterProperty['arns'] as unknown as string[],
160+
'Invalid input for ARNs: input should be a single string or an array of strings',
161+
);
162+
const groupedARNs = groupARNsByRegion(arns);
163+
164+
for (const region in groupedARNs) {
165+
const arnsForRegion = groupedARNs[region];
166+
const instanceIdFilter: Filter = {
167+
Name: 'instance-id',
168+
Values: arnsForRegion.map((arn) => parseArn(arn).resourceId),
169+
};
170+
for (const creds of credentials) {
171+
batches.push({
172+
creds,
173+
regions: [region] as [string, ...string[]],
174+
fetchFilters: [...filters, instanceIdFilter],
175+
});
176+
}
177+
}
178+
} else {
179+
const regions = convertToStringArrayWithValidation(
180+
filterProperty['regions'],
181+
'Invalid input for regions: input should be a single string or an array of strings',
182+
);
183+
for (const creds of credentials) {
184+
batches.push({
185+
creds,
186+
regions: regions as [string, ...string[]],
187+
fetchFilters: filters,
188+
});
189+
}
190+
}
191+
192+
return batches;
193+
}
194+
129195
function getFilters(context: any): Filter[] {
130196
const filters: Filter[] = [];
131197

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

Lines changed: 80 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+
getEc2InstancesAllowPartial: 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,77 @@ 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.getEc2InstancesAllowPartial.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(openopsCommonMock.getEc2InstancesAllowPartial).toHaveBeenCalledWith(
552+
'credentials',
553+
['us-east-1', 'eu-west-1'],
554+
false,
555+
[],
556+
);
557+
expect(openopsCommonMock.getEc2Instances).not.toHaveBeenCalled();
558+
});
559+
560+
test('when allowPartialResults, aggregates multiple credentials', async () => {
561+
openopsCommonMock.getCredentialsListFromAuth.mockResolvedValue([
562+
'cred-a',
563+
'cred-b',
564+
]);
565+
openopsCommonMock.getEc2InstancesAllowPartial
566+
.mockResolvedValueOnce({
567+
results: [{ id: 'a' }],
568+
failedRegions: [],
569+
})
570+
.mockResolvedValueOnce({
571+
results: [{ id: 'b' }],
572+
failedRegions: [{ region: 'us-west-2', accountId: '2', error: 'e' }],
573+
});
574+
575+
const context = {
576+
...jest.requireActual('@openops/blocks-framework'),
577+
auth: auth,
578+
propsValue: {
579+
filterProperty: { regions: ['us-east-1'] },
580+
dryRun: true,
581+
accounts: {},
582+
allowPartialResults: true,
583+
},
584+
};
585+
586+
const result = (await ec2GetInstancesAction.run(context)) as any;
587+
588+
expect(result.results).toEqual([{ id: 'a' }, { id: 'b' }]);
589+
expect(result.failedRegions).toEqual([
590+
{ region: 'us-west-2', accountId: '2', error: 'e' },
591+
]);
592+
expect(openopsCommonMock.getEc2InstancesAllowPartial).toHaveBeenCalledTimes(
593+
2,
594+
);
595+
});
517596
});

0 commit comments

Comments
 (0)