diff --git a/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-client.ts b/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-client.ts index b790660522..19ff0d0e50 100644 --- a/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-client.ts +++ b/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-client.ts @@ -3,7 +3,12 @@ import { GetRecommendationSummariesCommand, RecommendationSummary, } from '@aws-sdk/client-compute-optimizer'; -import { getAwsClient, makeAwsRequest } from '@openops/common'; +import { + getAwsClient, + isAwsPermissionError, + makeAwsRequest, +} from '@openops/common'; +import { logger } from '@openops/server-shared'; export async function getRecommendationSummaries( credentials: any, @@ -12,21 +17,33 @@ export async function getRecommendationSummaries( const results: RecommendationSummary[] = []; for (const region of regions) { - const client = getComputeOptimizerClient(credentials, region); - const command = new GetRecommendationSummariesCommand({ - nextToken: '', - }); - const regionalResults = await makeAwsRequest(client, command); + try { + const client = getComputeOptimizerClient(credentials, region); + const command = new GetRecommendationSummariesCommand({ + nextToken: '', + }); + const regionalResults = await makeAwsRequest(client, command); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - for (const result of regionalResults as any) { - const recommendationSummaries = result.recommendationSummaries?.map( - (item: any) => ({ ...item, region }), - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + for (const result of regionalResults as any) { + const recommendationSummaries = result.recommendationSummaries?.map( + (item: any) => ({ ...item, region }), + ); - if (recommendationSummaries) { - results.push(...recommendationSummaries); + if (recommendationSummaries) { + results.push(...recommendationSummaries); + } + } + } catch (error) { + if (isAwsPermissionError(error)) { + logger.debug('Skipping AWS region due to permission error', { + region, + error, + }); + continue; } + + throw error; } } diff --git a/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ebs-client.ts b/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ebs-client.ts index 0359b1b47a..100f81cc46 100644 --- a/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ebs-client.ts +++ b/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ebs-client.ts @@ -1,5 +1,6 @@ import { EBSFinding } from '@aws-sdk/client-compute-optimizer'; -import { groupARNsByRegion } from '@openops/common'; +import { groupARNsByRegion, isAwsPermissionError } from '@openops/common'; +import { logger } from '@openops/server-shared'; import { EbsRecommendationsBuilder } from './ebs-recommendations-builder'; import { ComputeOptimizerRecommendation } from './get-recommendations'; @@ -18,12 +19,24 @@ export async function getEbsRecommendationsForRegions( ); for (const region of regions) { - const recommendations = await recommendationsBuilder.getRecommendations( - credentials, - region, - ); + try { + const recommendations = await recommendationsBuilder.getRecommendations( + credentials, + region, + ); - result.push(...recommendations); + result.push(...recommendations); + } catch (error) { + if (isAwsPermissionError(error)) { + logger.debug('Skipping AWS region due to permission error', { + region, + error, + }); + continue; + } + + throw error; + } } return result; @@ -45,13 +58,25 @@ export async function getEbsRecommendationsForARNs( ); for (const region in arnsPerRegion) { - const recommendations = await recommendationsBuilder.getRecommendations( - credentials, - region, - arnsPerRegion[region], - ); + try { + const recommendations = await recommendationsBuilder.getRecommendations( + credentials, + region, + arnsPerRegion[region], + ); + + result.push(...recommendations); + } catch (error) { + if (isAwsPermissionError(error)) { + logger.debug('Skipping AWS region due to permission error', { + region, + error, + }); + continue; + } - result.push(...recommendations); + throw error; + } } return result; diff --git a/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ec2-client.ts b/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ec2-client.ts index ae4fe3bf09..1ed719edc8 100644 --- a/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ec2-client.ts +++ b/packages/blocks/aws-compute-optimizer/src/lib/common/compute-optimizer-ec2-client.ts @@ -1,5 +1,6 @@ import { Finding } from '@aws-sdk/client-compute-optimizer'; -import { groupARNsByRegion } from '@openops/common'; +import { groupARNsByRegion, isAwsPermissionError } from '@openops/common'; +import { logger } from '@openops/server-shared'; import { Ec2RecommendationsBuilder } from './ec2-recommendations-builder'; import { ComputeOptimizerRecommendation } from './get-recommendations'; @@ -18,12 +19,24 @@ export async function getEC2RecommendationsForRegions( ); for (const region of regions) { - const recommendations = await recommendationsBuilder.getRecommendations( - credentials, - region, - ); + try { + const recommendations = await recommendationsBuilder.getRecommendations( + credentials, + region, + ); - result.push(...recommendations); + result.push(...recommendations); + } catch (error) { + if (isAwsPermissionError(error)) { + logger.debug('Skipping AWS region due to permission error', { + region, + error, + }); + continue; + } + + throw error; + } } return result; @@ -46,13 +59,25 @@ export async function getEC2RecommendationsForARNs( ); for (const region in arnsPerRegion) { - const recommendations = await recommendationsBuilder.getRecommendations( - credentials, - region, - arnsPerRegion[region], - ); + try { + const recommendations = await recommendationsBuilder.getRecommendations( + credentials, + region, + arnsPerRegion[region], + ); + + result.push(...recommendations); + } catch (error) { + if (isAwsPermissionError(error)) { + logger.debug('Skipping AWS region due to permission error', { + region, + error, + }); + continue; + } - result.push(...recommendations); + throw error; + } } return result; diff --git a/packages/blocks/aws-compute-optimizer/test/compute-optimizer-client.test.ts b/packages/blocks/aws-compute-optimizer/test/compute-optimizer-client.test.ts index 6fe577d2c1..38305514a4 100644 --- a/packages/blocks/aws-compute-optimizer/test/compute-optimizer-client.test.ts +++ b/packages/blocks/aws-compute-optimizer/test/compute-optimizer-client.test.ts @@ -1,3 +1,17 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + const actual = jest.requireActual('@openops/server-shared'); + + return { + ...actual, + logger: { + ...actual.logger, + debug: debugMock, + }, + }; +}); + const CREDENTIALS = { accessKeyId: 'some accessKeyId', secretAccessKey: 'some secretAccessKey', @@ -160,6 +174,41 @@ describe('Get recommendations summary', () => { ]); expect(openopsCommonMock.getAwsClient).toHaveBeenCalledTimes(2); }); + + test('should skip permission denied regions when fetching recommendation summaries', async () => { + const summary = createRecommendationsSummaryResponse([ + { + recommendationResourceType: RecommendationSourceType.EBS_VOLUME, + }, + ]); + + openopsCommonMock.makeAwsRequest + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockResolvedValueOnce([summary]); + + const recommendations: any[] = await getRecommendationSummaries( + CREDENTIALS, + ['region1', 'region2'], + ); + + expect(recommendations).toHaveLength(1); + expect(recommendations[0].region).toBe('region2'); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when every recommendation summary region is denied', async () => { + openopsCommonMock.makeAwsRequest + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + + const recommendations = await getRecommendationSummaries(CREDENTIALS, [ + 'region1', + 'region2', + ]); + + expect(recommendations).toEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); }); function createRecommendationsSummaryResponse( diff --git a/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ebs-client.test.ts b/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ebs-client.test.ts index bf170d1796..90ce6a99a1 100644 --- a/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ebs-client.test.ts +++ b/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ebs-client.test.ts @@ -1,3 +1,17 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + const actual = jest.requireActual('@openops/server-shared'); + + return { + ...actual, + logger: { + ...actual.logger, + debug: debugMock, + }, + }; +}); + const CREDENTIALS = { accessKeyId: 'some accessKeyId', secretAccessKey: 'some secretAccessKey', @@ -156,6 +170,41 @@ describe('Get ebs volumes recommendations', () => { expect(recommendations.length).toBe(0); }); + test('should skip permission denied regions for EBS recommendations', async () => { + const recommendationsInRegion = createRecommendationsResponse([ + createRecommendations('arn:aws:ec2:us-east-1:123456789123:volume/vol-1'), + ]); + + sendMock + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockResolvedValueOnce([recommendationsInRegion]); + + const recommendations = await getEbsRecommendationsForRegions( + CREDENTIALS, + EBSFinding.OPTIMIZED, + ['eu-west-1', 'us-east-1'], + ); + + expect(recommendations.map((result) => result.arn)).toEqual([ + 'arn:aws:ec2:us-east-1:123456789123:volume/vol-1', + ]); + }); + + test('should return empty array when all EBS recommendation regions are denied', async () => { + sendMock + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + + const recommendations = await getEbsRecommendationsForRegions( + CREDENTIALS, + EBSFinding.NOT_OPTIMIZED, + ['eu-west-1', 'us-east-1'], + ); + + expect(recommendations).toEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); + test('should return all the Ebs Volumes Recommendations for the provided volumes', async () => { const recommendationsInRegion1 = createRecommendationsResponse([ createRecommendations('arn:aws:ec2:us-east-1:123456789123:volume/vol-1'), @@ -222,6 +271,31 @@ describe('Get ebs volumes recommendations', () => { expect(recommendations.length).toBe(0); }); + test('should skip permission denied arn regions for EBS recommendations', async () => { + sendMock + .mockResolvedValueOnce([ + createRecommendationsResponse([ + createRecommendations( + 'arn:aws:ec2:us-east-1:123456789123:volume/vol-1', + ), + ]), + ]) + .mockRejectedValueOnce(new Error('AccessDenied')); + + const recommendations = await getEbsRecommendationsForARNs( + CREDENTIALS, + EBSFinding.OPTIMIZED, + [ + 'arn:aws:ec2:us-east-1:123456789123:volume/vol-1', + 'arn:aws:ec2:us-east-2:123456789123:volume/vol-2', + ], + ); + + expect(recommendations.map((result) => result.arn)).toEqual([ + 'arn:aws:ec2:us-east-1:123456789123:volume/vol-1', + ]); + }); + test('should return an empty array when the given volumes have 0 savings recommendations', async () => { const findingType = EBSFinding.OPTIMIZED; const recommendationZeroSaving = createRecommendations( diff --git a/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ec2-client.test.ts b/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ec2-client.test.ts index 2a1801e32c..411a05beee 100644 --- a/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ec2-client.test.ts +++ b/packages/blocks/aws-compute-optimizer/test/compute-optimizer-ec2-client.test.ts @@ -1,3 +1,17 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + const actual = jest.requireActual('@openops/server-shared'); + + return { + ...actual, + logger: { + ...actual.logger, + debug: debugMock, + }, + }; +}); + const CREDENTIALS = { accessKeyId: 'some accessKeyId', secretAccessKey: 'some secretAccessKey', @@ -156,6 +170,45 @@ describe('Get ec2 instances recommendations', () => { expect(recommendations.length).toBe(0); }); + test('should skip permission denied regions for EC2 recommendations', async () => { + const recommendationsInRegion = createRecommendationsResponse([ + createRecommendations( + 'arn:aws:ec2:us-east-1:123456789123:instance/i-1', + Finding.OPTIMIZED, + ), + ]); + + sendMock + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockResolvedValueOnce([recommendationsInRegion]); + + const recommendations = await getEC2RecommendationsForRegions( + CREDENTIALS, + Finding.OPTIMIZED, + ['eu-west-1', 'us-east-1'], + ); + + expect(recommendations.map((result) => result.arn)).toEqual([ + 'arn:aws:ec2:us-east-1:123456789123:instance/i-1', + ]); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when all EC2 recommendation regions are denied', async () => { + sendMock + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + + const recommendations = await getEC2RecommendationsForRegions( + CREDENTIALS, + Finding.OVER_PROVISIONED, + ['eu-west-1', 'us-east-1'], + ); + + expect(recommendations).toEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); + test('should return all the EC2 Recommendations for the provided instances', async () => { const recommendationsInRegion1 = createRecommendationsResponse([ createRecommendations('arn:aws:ec2:us-east-2:123456789123:instance/i-1'), @@ -222,6 +275,31 @@ describe('Get ec2 instances recommendations', () => { expect(recommendations.length).toBe(0); }); + test('should skip permission denied arn regions for EC2 recommendations', async () => { + sendMock + .mockResolvedValueOnce([ + createRecommendationsResponse([ + createRecommendations( + 'arn:aws:ec2:us-east-2:123456789123:instance/i-1', + ), + ]), + ]) + .mockRejectedValueOnce(new Error('AccessDenied')); + + const recommendations = await getEC2RecommendationsForARNs( + CREDENTIALS, + Finding.OPTIMIZED, + [ + 'arn:aws:ec2:us-east-2:123456789123:instance/i-1', + 'arn:aws:ec2:us-east-1:123456789123:instance/i-2', + ], + ); + + expect(recommendations.map((result) => result.arn)).toEqual([ + 'arn:aws:ec2:us-east-2:123456789123:instance/i-1', + ]); + }); + test('should return an empty array when the given ec2 have 0 savings recommendations', async () => { const findingType = Finding.OPTIMIZED; const recommendationZeroSaving = createRecommendations( diff --git a/packages/openops/src/index.ts b/packages/openops/src/index.ts index 39e42e27de..546cfaaad4 100644 --- a/packages/openops/src/index.ts +++ b/packages/openops/src/index.ts @@ -15,7 +15,9 @@ export * from './lib/aws/ec2/ec2-get-instances'; export * from './lib/aws/ec2/ec2-instance-state-manager'; export * from './lib/aws/ec2/ec2-modify-instance-attribute'; export * from './lib/aws/ec2/ec2-terminate-instances'; +export * from './lib/aws/fetch-arrays-across-regions'; export * from './lib/aws/get-client'; +export * from './lib/aws/is-aws-permission-error'; export * from './lib/aws/organizations-common'; export * from './lib/aws/rds/rds-create-snapshot'; export * from './lib/aws/rds/rds-delete-instance'; diff --git a/packages/openops/src/lib/aws/ebs/get-ebs-snapshots.ts b/packages/openops/src/lib/aws/ebs/get-ebs-snapshots.ts index 83aaa3c2d8..61e368bc29 100644 --- a/packages/openops/src/lib/aws/ebs/get-ebs-snapshots.ts +++ b/packages/openops/src/lib/aws/ebs/get-ebs-snapshots.ts @@ -1,4 +1,5 @@ import * as EC2 from '@aws-sdk/client-ec2'; +import { fetchArraysAcrossRegions } from '../fetch-arrays-across-regions'; import { getAwsClient } from '../get-client'; export async function getEbsSnapshots( @@ -25,8 +26,5 @@ export async function getEbsSnapshots( ); }; - const snapshotsFromAllRegions = await Promise.all( - regions.map(fetchSnapshotsInRegion), - ); - return snapshotsFromAllRegions.flat(); + return fetchArraysAcrossRegions(regions, fetchSnapshotsInRegion); } diff --git a/packages/openops/src/lib/aws/ebs/get-ebs-volumes.ts b/packages/openops/src/lib/aws/ebs/get-ebs-volumes.ts index 46017beffe..43c65c43a2 100644 --- a/packages/openops/src/lib/aws/ebs/get-ebs-volumes.ts +++ b/packages/openops/src/lib/aws/ebs/get-ebs-volumes.ts @@ -1,5 +1,6 @@ import * as EC2 from '@aws-sdk/client-ec2'; import * as ArnParser from '@aws-sdk/util-arn-parser'; +import { fetchArraysAcrossRegions } from '../fetch-arrays-across-regions'; import { getAwsClient } from '../get-client'; import { getAccountName } from '../organizations-common'; import { getAccountId } from '../sts-common'; @@ -29,10 +30,7 @@ export async function getEbsVolumes( ); }; - const volumesFromAllRegions = await Promise.all( - regions.map(fetchVolumesInRegion), - ); - return volumesFromAllRegions.flat(); + return fetchArraysAcrossRegions(regions, fetchVolumesInRegion); } function mapVolumeToOpenOpsVolume( diff --git a/packages/openops/src/lib/aws/ec2/ec2-get-instances.ts b/packages/openops/src/lib/aws/ec2/ec2-get-instances.ts index 8927786dc7..540a23f8f5 100644 --- a/packages/openops/src/lib/aws/ec2/ec2-get-instances.ts +++ b/packages/openops/src/lib/aws/ec2/ec2-get-instances.ts @@ -1,5 +1,6 @@ import * as EC2 from '@aws-sdk/client-ec2'; import * as ArnParser from '@aws-sdk/util-arn-parser'; +import { fetchArraysAcrossRegions } from '../fetch-arrays-across-regions'; import { getAwsClient } from '../get-client'; import { getAccountName } from '../organizations-common'; import { getAccountId } from '../sts-common'; @@ -37,10 +38,7 @@ export async function getEc2Instances( ); }; - const instancesFromAllRegions = await Promise.all( - regions.map(fetchInstancesInRegion), - ); - return instancesFromAllRegions.flat(); + return fetchArraysAcrossRegions(regions, fetchInstancesInRegion); } function mapInstanceToOpenOpsEc2Instance( diff --git a/packages/openops/src/lib/aws/fetch-arrays-across-regions.ts b/packages/openops/src/lib/aws/fetch-arrays-across-regions.ts new file mode 100644 index 0000000000..38856a2948 --- /dev/null +++ b/packages/openops/src/lib/aws/fetch-arrays-across-regions.ts @@ -0,0 +1,42 @@ +import { logger } from '@openops/server-shared'; +import { isAwsPermissionError } from './is-aws-permission-error'; + +export async function fetchArraysAcrossRegions( + regions: readonly string[], + fetchPerRegion: (region: string) => Promise, +): Promise { + if (regions.length === 0) { + return []; + } + + const settled = await Promise.allSettled( + regions.map((region) => fetchPerRegion(region)), + ); + + const results: T[] = []; + let unexpectedError: unknown; + + settled.forEach((outcome, index) => { + if (outcome.status === 'fulfilled') { + results.push(...outcome.value); + return; + } + + const region = regions[index]; + if (isAwsPermissionError(outcome.reason)) { + logger.debug('Skipping AWS region due to permission error', { + region, + error: outcome.reason, + }); + return; + } + + unexpectedError ??= outcome.reason; + }); + + if (unexpectedError) { + throw unexpectedError; + } + + return results; +} diff --git a/packages/openops/src/lib/aws/is-aws-permission-error.ts b/packages/openops/src/lib/aws/is-aws-permission-error.ts new file mode 100644 index 0000000000..d1dd3be850 --- /dev/null +++ b/packages/openops/src/lib/aws/is-aws-permission-error.ts @@ -0,0 +1,51 @@ +const AWS_PERMISSION_ERROR_CODES = new Set([ + 'AccessDenied', + 'AccessDeniedException', + 'AccessDeniedFault', + 'Unauthorized', + 'UnauthorizedOperation', + 'AuthFailure', + 'ForbiddenException', + 'NotAuthorized', + 'NotAuthorizedException', +]); + +const AWS_PERMISSION_ERROR_PATTERN = + /access\s*denied|accessdenied|not\s+authorized|unauthorized|authfailure|forbidden/i; + +export function isAwsPermissionError(error: unknown): boolean { + if (typeof error === 'string') { + return AWS_PERMISSION_ERROR_PATTERN.test(error); + } + + if (!error || typeof error !== 'object') { + return false; + } + + const awsError = error as { + name?: string; + code?: string; + Code?: string; + message?: string; + Message?: string; + $metadata?: { httpStatusCode?: number }; + statusCode?: number; + }; + + const errorCode = awsError.name ?? awsError.code ?? awsError.Code ?? ''; + const message = awsError.message ?? awsError.Message ?? ''; + const errorText = `${errorCode} ${message}`.trim(); + const httpStatusCode = + awsError.$metadata?.httpStatusCode ?? awsError.statusCode; + + if (AWS_PERMISSION_ERROR_CODES.has(errorCode)) { + return true; + } + + return ( + AWS_PERMISSION_ERROR_PATTERN.test(errorText) && + (httpStatusCode === undefined || + httpStatusCode === 401 || + httpStatusCode === 403) + ); +} diff --git a/packages/openops/src/lib/aws/rds/rds-describe.ts b/packages/openops/src/lib/aws/rds/rds-describe.ts index 00a2c6bc17..97e0f67ece 100644 --- a/packages/openops/src/lib/aws/rds/rds-describe.ts +++ b/packages/openops/src/lib/aws/rds/rds-describe.ts @@ -1,4 +1,5 @@ import * as RDS from '@aws-sdk/client-rds'; +import { fetchArraysAcrossRegions } from '../fetch-arrays-across-regions'; import { getAwsClient } from '../get-client'; export async function describeRdsSnapshots( @@ -23,10 +24,7 @@ export async function describeRdsSnapshots( ); }; - const snapshotsFromAllRegions = await Promise.all( - regions.map(fetchSnapshotsInRegion), - ); - return snapshotsFromAllRegions.flat(); + return fetchArraysAcrossRegions(regions, fetchSnapshotsInRegion); } export async function describeRdsInstances( @@ -51,8 +49,5 @@ export async function describeRdsInstances( ); }; - const instancesFromAllRegions = await Promise.all( - regions.map(fetchInstancesInRegion), - ); - return instancesFromAllRegions.flat(); + return fetchArraysAcrossRegions(regions, fetchInstancesInRegion); } diff --git a/packages/openops/test/aws/ebs/ebs-get-snapshots.test.ts b/packages/openops/test/aws/ebs/ebs-get-snapshots.test.ts index 0289f55db2..45565c8240 100644 --- a/packages/openops/test/aws/ebs/ebs-get-snapshots.test.ts +++ b/packages/openops/test/aws/ebs/ebs-get-snapshots.test.ts @@ -1,3 +1,13 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + return { + logger: { + debug: debugMock, + }, + }; +}); + const describeSnapshotsCommandMock = jest.fn(); jest.mock('@aws-sdk/client-ec2', () => { return { @@ -73,4 +83,50 @@ describe('getEbsSnapshots', () => { OwnerIds: ['self'], }); }); + + test('should skip permission denied snapshot regions', async () => { + const sendMock = jest + .fn() + .mockResolvedValueOnce({ + Snapshots: [{ SnapshotId: 'mockResult1', Status: 'pending' }], + }) + .mockRejectedValueOnce(new Error('AccessDenied')); + + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await getEbsSnapshots( + CREDENTIALS, + ['some-region1', 'some-region2'], + false, + [], + ); + + expect(result).toStrictEqual([ + { SnapshotId: 'mockResult1', Status: 'pending', region: 'some-region1' }, + ]); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when all snapshot regions are denied', async () => { + const sendMock = jest + .fn() + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await getEbsSnapshots( + CREDENTIALS, + ['some-region1', 'some-region2'], + false, + [], + ); + + expect(result).toStrictEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/openops/test/aws/ebs/ebs-get-volumes.test.ts b/packages/openops/test/aws/ebs/ebs-get-volumes.test.ts index 3b5bc0148f..aa78aae8d7 100644 --- a/packages/openops/test/aws/ebs/ebs-get-volumes.test.ts +++ b/packages/openops/test/aws/ebs/ebs-get-volumes.test.ts @@ -1,3 +1,13 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + return { + logger: { + debug: debugMock, + }, + }; +}); + const describeVolumesCommandMock = jest.fn(); jest.mock('@aws-sdk/client-ec2', () => { return { @@ -171,4 +181,50 @@ describe('getEbsVolumes', () => { DryRun: false, }); }); + + test('should skip permission denied volume regions', async () => { + const sendMock = jest + .fn() + .mockResolvedValueOnce({ + Volumes: [{ VolumeId: 'mockResult1', VolumeType: 'gp2', Size: 100 }], + }) + .mockRejectedValueOnce(new Error('AccessDenied')); + + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await getEbsVolumes( + CREDENTIALS, + ['some-region1', 'some-region2'], + false, + [], + ); + + expect(result).toHaveLength(1); + expect(result[0].volume_id).toBe('mockResult1'); + expect(result[0].region).toBe('some-region1'); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when all volume regions are denied', async () => { + const sendMock = jest + .fn() + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await getEbsVolumes( + CREDENTIALS, + ['some-region1', 'some-region2'], + false, + [], + ); + + expect(result).toEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/openops/test/aws/ec2/ec2-get-instances.test.ts b/packages/openops/test/aws/ec2/ec2-get-instances.test.ts index 2145da3eeb..6c724daabb 100644 --- a/packages/openops/test/aws/ec2/ec2-get-instances.test.ts +++ b/packages/openops/test/aws/ec2/ec2-get-instances.test.ts @@ -1,3 +1,13 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + return { + logger: { + debug: debugMock, + }, + }; +}); + const describeInstancesCommandMock = jest.fn(); jest.mock('@aws-sdk/client-ec2', () => { return { @@ -172,4 +182,56 @@ describe('getEc2Instances', () => { DryRun: true, }); }); + + test('should skip permission denied regions', async () => { + const sendMock = jest + .fn() + .mockResolvedValueOnce({ + Reservations: [ + { + Instances: [ + { InstanceId: 'mockResult1', InstanceType: 'c1.medium' }, + ], + }, + ], + }) + .mockRejectedValueOnce(new Error('AccessDenied')); + + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await getEc2Instances( + CREDENTIALS, + ['some-region1', 'some-region2'], + false, + [], + ); + + expect(result).toHaveLength(1); + expect(result[0].instance_id).toBe('mockResult1'); + expect(result[0].region).toBe('some-region1'); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when all regions are denied', async () => { + const sendMock = jest + .fn() + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await getEc2Instances( + CREDENTIALS, + ['some-region1', 'some-region2'], + false, + [], + ); + + expect(result).toEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/openops/test/aws/fetch-arrays-across-regions.test.ts b/packages/openops/test/aws/fetch-arrays-across-regions.test.ts new file mode 100644 index 0000000000..a51fa35af7 --- /dev/null +++ b/packages/openops/test/aws/fetch-arrays-across-regions.test.ts @@ -0,0 +1,75 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + return { + logger: { + debug: debugMock, + }, + }; +}); + +import { fetchArraysAcrossRegions } from '../../src/lib/aws/fetch-arrays-across-regions'; + +describe('fetchArraysAcrossRegions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('merges results from all successful regions', async () => { + const result = await fetchArraysAcrossRegions( + ['r1', 'r2'], + async (region) => [{ region }], + ); + + expect(result).toEqual([{ region: 'r1' }, { region: 'r2' }]); + expect(debugMock).not.toHaveBeenCalled(); + }); + + test('skips permission errors and debugs', async () => { + const result = await fetchArraysAcrossRegions( + ['ok', 'denied'], + async (region) => { + if (region === 'denied') { + throw new Error('AccessDenied'); + } + + return [{ region }]; + }, + ); + + expect(result).toEqual([{ region: 'ok' }]); + expect(debugMock).toHaveBeenCalledWith( + 'Skipping AWS region due to permission error', + expect.objectContaining({ + region: 'denied', + error: expect.any(Error), + }), + ); + }); + + test('returns empty array when every region fails with permission errors', async () => { + const result = await fetchArraysAcrossRegions( + ['r1', 'r2'], + async (region) => { + throw new Error(`${region} AccessDenied`); + }, + ); + + expect(result).toEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); + + test('rethrows non-permission failures', async () => { + await expect( + fetchArraysAcrossRegions(['r1', 'r2'], async (region) => { + if (region === 'r1') { + return [{ region }]; + } + + throw new Error('boom'); + }), + ).rejects.toThrow('boom'); + + expect(debugMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/openops/test/aws/rds/rds-describe.test.ts b/packages/openops/test/aws/rds/rds-describe.test.ts index a9a06b487f..7f17c95a5d 100644 --- a/packages/openops/test/aws/rds/rds-describe.test.ts +++ b/packages/openops/test/aws/rds/rds-describe.test.ts @@ -1,3 +1,13 @@ +const debugMock = jest.fn(); + +jest.mock('@openops/server-shared', () => { + return { + logger: { + debug: debugMock, + }, + }; +}); + jest.mock('@aws-sdk/client-rds'); import * as RDS from '@aws-sdk/client-rds'; @@ -59,6 +69,42 @@ describe('describeRdsSnapshots', () => { expect(sendMock).toHaveBeenCalledTimes(2); }); + test('should skip permission denied regions for snapshots', async () => { + const sendMock = jest + .fn() + .mockResolvedValueOnce({ DBSnapshots: [{ obj: '1' }] }) + .mockRejectedValueOnce(new Error('AccessDenied')); + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await describeRdsSnapshots('credentials', [ + 'some-region1', + 'some-region2', + ]); + + expect(result).toStrictEqual([{ obj: '1', region: 'some-region1' }]); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when all snapshot regions are denied', async () => { + const sendMock = jest + .fn() + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await describeRdsSnapshots('credentials', [ + 'some-region1', + 'some-region2', + ]); + + expect(result).toStrictEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); + test(`should throw if send throws`, async () => { const sendMock = jest.fn().mockRejectedValue(new Error('some error')); getAwsClientMock.getAwsClient.mockImplementation(() => ({ @@ -66,7 +112,7 @@ describe('describeRdsSnapshots', () => { })); await expect( - describeRdsInstances('credentials', ['some-region1'], []), + describeRdsSnapshots('credentials', ['some-region1'], []), ).rejects.toThrow('some error'); expect(sendMock).toHaveBeenCalledTimes(1); }); @@ -112,6 +158,44 @@ describe('describeRdsInstances', () => { expect(sendMock).toHaveBeenCalledTimes(2); }); + test('should skip permission denied regions for instances', async () => { + const sendMock = jest + .fn() + .mockResolvedValueOnce({ DBInstances: [{ instance: 'instance1' }] }) + .mockRejectedValueOnce(new Error('AccessDenied')); + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await describeRdsInstances('credentials', [ + 'some-region1', + 'some-region2', + ]); + + expect(result).toStrictEqual([ + { instance: 'instance1', region: 'some-region1' }, + ]); + expect(debugMock).toHaveBeenCalledTimes(1); + }); + + test('should return empty array when all instance regions are denied', async () => { + const sendMock = jest + .fn() + .mockRejectedValueOnce(new Error('AccessDenied')) + .mockRejectedValueOnce(new Error('UnauthorizedOperation')); + getAwsClientMock.getAwsClient.mockImplementation(() => ({ + send: sendMock, + })); + + const result = await describeRdsInstances('credentials', [ + 'some-region1', + 'some-region2', + ]); + + expect(result).toStrictEqual([]); + expect(debugMock).toHaveBeenCalledTimes(2); + }); + test(`should throw if send throws`, async () => { const sendMock = jest.fn().mockRejectedValue(new Error('some error')); getAwsClientMock.getAwsClient.mockImplementation(() => ({