diff --git a/lib/plugins/aws/deploy-list.js b/lib/plugins/aws/deploy-list.js index 1ce3fb081..cca01e514 100644 --- a/lib/plugins/aws/deploy-list.js +++ b/lib/plugins/aws/deploy-list.js @@ -1,10 +1,20 @@ 'use strict'; +const promiseLimit = require('ext/promise/limit').bind(Promise); const { log, writeText } = require('../../utils/serverless-utils/log'); const validate = require('./lib/validate'); -const findAndGroupDeployments = require('./utils/find-and-group-deployments'); +const parseDeploymentObjectKey = require('./utils/parse-deployment-object-key'); const setBucketName = require('./lib/set-bucket-name'); const ServerlessError = require('../../serverless-error'); +const { S3Client, paginateListObjectsV2 } = require('@aws-sdk/client-s3'); +const { + LambdaClient, + GetFunctionCommand, + ListVersionsByFunctionCommand, +} = require('@aws-sdk/client-lambda'); +const { isS3ListObjectsAccessDeniedError } = require('./utils/aws-sdk-v3-error'); + +const limitLambdaRequests = promiseLimit(6, async (task) => task()); class AwsDeployList { constructor(serverless, options) { @@ -31,50 +41,71 @@ class AwsDeployList { const stage = this.provider.getStage(); const prefix = this.provider.getDeploymentPrefix(); - let response; + let foundDeployment = false; + let currentDirectory = null; + let currentDeployment = []; + const flushDeployment = () => { + if (!currentDeployment.length) return; + this.printDeployment(currentDeployment); + foundDeployment = true; + currentDeployment = []; + }; + try { - response = await this.provider.request('S3', 'listObjectsV2', { - Bucket: this.bucketName, - Prefix: `${prefix}/${service}/${stage}/`, - }); + const s3 = new S3Client(await this.provider.getAwsSdkV3Config()); + const paginator = paginateListObjectsV2( + { client: s3 }, + { + Bucket: this.bucketName, + Prefix: `${prefix}/${service}/${stage}/`, + } + ); + for await (const page of paginator) { + for (const object of page.Contents || []) { + const entry = parseDeploymentObjectKey(object.Key, prefix, service, stage); + if (!entry) continue; + + if (currentDirectory && entry.directory !== currentDirectory) flushDeployment(); + currentDirectory = entry.directory; + currentDeployment.push(entry); + } + } + flushDeployment(); } catch (err) { - if (err.code === 'AWS_S3_LIST_OBJECTS_V2_ACCESS_DENIED') { + if (isS3ListObjectsAccessDeniedError(err)) { throw new ServerlessError( 'Could not list objects in the deployment bucket. Make sure you have sufficient permissions to access it.', - err.code + 'AWS_S3_LIST_OBJECTS_V2_ACCESS_DENIED' ); } throw err; } - const directoryRegex = new RegExp('(.+)-(.+-.+-.+)'); - const deployments = findAndGroupDeployments(response, prefix, service, stage); - - if (deployments.length === 0) { + if (!foundDeployment) { log.notice(); log.notice.skip( "No deployments found, if that's unexpected ensure that stage and region are correct" ); - return; } + } - deployments.forEach((deployment) => { - const match = deployment[0].directory.match(directoryRegex); - const date = new Date(Date.parse(match[2])); - writeText( - `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, 0)}-${String( - date.getUTCDate() - ).padStart(2, 0)} ` + - `${String(date.getUTCHours()).padStart(2, 0)}:${String(date.getUTCMinutes()).padStart( - 2, - 0 - )}:${String(date.getUTCSeconds()).padStart(2, 0)} UTC`, - `Timestamp: ${match[1]}`, - 'Files:' - ); - deployment.forEach((entry) => { - writeText(` - ${entry.file}`); - }); + printDeployment(deployment) { + const directoryRegex = new RegExp('(.+)-(.+-.+-.+)'); + const match = deployment[0].directory.match(directoryRegex); + const date = new Date(Date.parse(match[2])); + writeText( + `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, 0)}-${String( + date.getUTCDate() + ).padStart(2, 0)} ` + + `${String(date.getUTCHours()).padStart(2, 0)}:${String(date.getUTCMinutes()).padStart( + 2, + 0 + )}:${String(date.getUTCSeconds()).padStart(2, 0)} UTC`, + `Timestamp: ${match[1]}`, + 'Files:' + ); + deployment.forEach((entry) => { + writeText(` - ${entry.file}`); }); } @@ -87,6 +118,7 @@ class AwsDeployList { async getFunctions() { const funcs = this.serverless.service.getAllFunctionsNames(); + const lambda = new LambdaClient(await this.provider.getAwsSdkV3Config()); const result = await Promise.all( funcs.map((funcName) => { @@ -94,21 +126,23 @@ class AwsDeployList { FunctionName: funcName, }; - return this.provider.request('Lambda', 'getFunction', params); + return limitLambdaRequests(() => lambda.send(new GetFunctionCommand(params))); }) ); return result.map((item) => item.Configuration); } - async getFunctionPaginatedVersions(params, totalVersions) { - const response = await this.provider.request('Lambda', 'listVersionsByFunction', params); + async getFunctionPaginatedVersions(params, totalVersions, lambda) { + if (!lambda) lambda = new LambdaClient(await this.provider.getAwsSdkV3Config()); + const response = await lambda.send(new ListVersionsByFunctionCommand(params)); const Versions = (totalVersions || []).concat(response.Versions); if (response.NextMarker) { return this.getFunctionPaginatedVersions( { ...params, Marker: response.NextMarker }, - Versions + Versions, + lambda ); } @@ -116,13 +150,16 @@ class AwsDeployList { } async getFunctionVersions(funcs) { + const lambda = new LambdaClient(await this.provider.getAwsSdkV3Config()); return Promise.all( funcs.map((func) => { const params = { FunctionName: func.FunctionName, }; - return this.getFunctionPaginatedVersions(params); + return limitLambdaRequests(() => + this.getFunctionPaginatedVersions(params, undefined, lambda) + ); }) ); } diff --git a/lib/plugins/aws/deploy/lib/check-for-changes.js b/lib/plugins/aws/deploy/lib/check-for-changes.js index 5b8187e70..06099ed0e 100644 --- a/lib/plugins/aws/deploy/lib/check-for-changes.js +++ b/lib/plugins/aws/deploy/lib/check-for-changes.js @@ -4,16 +4,35 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { isDeepStrictEqual } = require('node:util'); +const promiseLimit = require('ext/promise/limit').bind(Promise); const glob = require('../../../../utils/glob'); const normalizeFiles = require('../../lib/normalize-files'); +const parseDeploymentObjectKey = require('../../utils/parse-deployment-object-key'); const ServerlessError = require('../../../../serverless-error'); const log = require('../../../../utils/serverless-utils/log').log.get('check-for-changes'); +const { S3Client, HeadObjectCommand, paginateListObjectsV2 } = require('@aws-sdk/client-s3'); +const { LambdaClient, GetFunctionCommand } = require('@aws-sdk/client-lambda'); +const { + CloudWatchLogsClient, + DescribeSubscriptionFiltersCommand, +} = require('@aws-sdk/client-cloudwatch-logs'); +const { + CloudFormationClient, + DescribeStackResourceCommand, +} = require('@aws-sdk/client-cloudformation'); +const { + isS3ListObjectsNoSuchBucketError, + isS3HeadObjectForbiddenError, + isLambdaAccessDeniedError, +} = require('../../utils/aws-sdk-v3-error'); const fsp = fs.promises; -const isDeploymentDirToken = RegExp.prototype.test.bind( - /^[\d]+-\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/ -); +const limitLambdaRequests = promiseLimit(6, async (task) => task()); +const limitS3Requests = promiseLimit(6, async (task) => task()); +const limitCloudWatchLogsRequests = promiseLimit(2, async (task) => task()); +const limitCloudFormationRequests = promiseLimit(6, async (task) => task()); + const isOtelExtensionName = RegExp.prototype.test.bind(/^sls-otel\.\d+\.\d+\.\d+\.zip$/); module.exports = { @@ -38,61 +57,65 @@ module.exports = { async getMostRecentObjects() { const service = this.serverless.service.service; + const stage = this.provider.getStage(); + const prefix = this.provider.getDeploymentPrefix(); const params = { Bucket: this.bucketName, - Prefix: `${this.provider.getDeploymentPrefix()}/${service}/${this.provider.getStage()}/`, + Prefix: `${prefix}/${service}/${stage}/`, }; - const result = await (async () => { - try { - return await this.provider.request('S3', 'listObjectsV2', params); - } catch (error) { - if (!error.message.includes('The specified bucket does not exist')) throw error; - const stackName = this.provider.naming.getStackName(); - throw new ServerlessError( - [ - `The serverless deployment bucket "${params.Bucket}" does not exist.`, - `Create it manually if you want to reuse the CloudFormation stack "${stackName}",`, - 'or delete the stack if it is no longer required.', - ].join(' '), - 'DEPLOYMENT_BUCKET_DOES_NOT_EXIST' - ); - } - })(); - const contents = result && result.Contents; - const objects = contents - ? contents.filter(({ Key: key }) => isDeploymentDirToken(key.split('/')[3])) - : []; - if (!objects.length) { - return []; - } - - const ordered = [...objects].sort((objectA, objectB) => objectB.Key.localeCompare(objectA.Key)); - - const firstKey = ordered[0].Key; - const directory = firstKey.substring(0, firstKey.lastIndexOf('/')); + try { + const s3 = new S3Client(await this.provider.getAwsSdkV3Config()); + let latestDirectory = null; + let latestObjects = []; + + for await (const page of paginateListObjectsV2({ client: s3 }, params)) { + for (const object of page.Contents || []) { + const entry = parseDeploymentObjectKey(object.Key, prefix, service, stage); + if (!entry) continue; + + if (!latestDirectory || entry.directory > latestDirectory) { + latestDirectory = entry.directory; + latestObjects = [object]; + continue; + } - return ordered.filter((obj) => { - const objKey = obj.Key; - const objDirectory = objKey.substring(0, objKey.lastIndexOf('/')); + if (entry.directory === latestDirectory) latestObjects.push(object); + } + } - return directory === objDirectory; - }); + return latestObjects.sort((objectA, objectB) => objectB.Key.localeCompare(objectA.Key)); + } catch (error) { + if (!isS3ListObjectsNoSuchBucketError(error)) throw error; + const stackName = this.provider.naming.getStackName(); + throw new ServerlessError( + [ + `The serverless deployment bucket "${params.Bucket}" does not exist.`, + `Create it manually if you want to reuse the CloudFormation stack "${stackName}",`, + 'or delete the stack if it is no longer required.', + ].join(' '), + 'DEPLOYMENT_BUCKET_DOES_NOT_EXIST' + ); + } }, // Gives the least recent last modify date across all the functions in the service. async getFunctionsEarliestLastModifiedDate() { let couldNotAccessFunction = false; + const lambda = new LambdaClient(await this.provider.getAwsSdkV3Config()); const getFunctionResults = this.serverless.service.getAllFunctions().map((funName) => { const functionObj = this.serverless.service.getFunction(funName); - return this.provider - .request('Lambda', 'getFunction', { - FunctionName: functionObj.name, - }) + return limitLambdaRequests(() => + lambda.send( + new GetFunctionCommand({ + FunctionName: functionObj.name, + }) + ) + ) .then((res) => new Date(res.Configuration.LastModified)) .catch((err) => { - if (err.providerError && err.providerError.statusCode === 403) { + if (isLambdaAccessDeniedError(err)) { couldNotAccessFunction = true; } return new Date(0); @@ -114,20 +137,25 @@ module.exports = { }, async getObjectMetadata(objects) { + const s3 = new S3Client(await this.provider.getAwsSdkV3Config()); return Promise.all( objects.map(async (obj) => { try { - const result = await this.provider.request('S3', 'headObject', { - Bucket: this.bucketName, - Key: obj.Key, - }); + const result = await limitS3Requests(() => + s3.send( + new HeadObjectCommand({ + Bucket: this.bucketName, + Key: obj.Key, + }) + ) + ); result.Key = obj.Key; return result; } catch (err) { - if (err.code === 'AWS_S3_HEAD_OBJECT_FORBIDDEN') { + if (isS3HeadObjectForbiddenError(err)) { throw new ServerlessError( 'Could not access objects in the deployment bucket. Make sure you have sufficient permissions to access it.', - err.code + 'AWS_S3_HEAD_OBJECT_FORBIDDEN' ); } throw err; @@ -300,11 +328,10 @@ module.exports = { const cloudwatchLogEvents = params.cloudwatchLogEvents; const CLOUDWATCHLOG_LOG_GROUP_EVENT_PER_FUNCTION_LIMIT = 2; - const response = await this.provider - .request('CloudWatchLogs', 'describeSubscriptionFilters', { - logGroupName, - }) - .catch(() => ({ subscriptionFilters: [] })); + const cloudWatchLogs = new CloudWatchLogsClient(await this.provider.getAwsSdkV3Config()); + const response = await limitCloudWatchLogsRequests(() => + cloudWatchLogs.send(new DescribeSubscriptionFiltersCommand({ logGroupName })) + ).catch(() => ({ subscriptionFilters: [] })); if (response.subscriptionFilters.length === 0) { log.debug('no subscription filters detected'); return false; @@ -394,13 +421,14 @@ module.exports = { async isInternalSubscriptionFilter(stackName, logicalResourceId, physicalResourceId) { try { - const { StackResourceDetail } = await this.provider.request( - 'CloudFormation', - 'describeStackResource', - { - StackName: stackName, - LogicalResourceId: logicalResourceId, - } + const cloudFormation = new CloudFormationClient(await this.provider.getAwsSdkV3Config()); + const { StackResourceDetail } = await limitCloudFormationRequests(() => + cloudFormation.send( + new DescribeStackResourceCommand({ + StackName: stackName, + LogicalResourceId: logicalResourceId, + }) + ) ); return physicalResourceId === StackResourceDetail.PhysicalResourceId; diff --git a/lib/plugins/aws/deploy/lib/ensure-valid-bucket-exists.js b/lib/plugins/aws/deploy/lib/ensure-valid-bucket-exists.js index bb7acc2df..fea453206 100644 --- a/lib/plugins/aws/deploy/lib/ensure-valid-bucket-exists.js +++ b/lib/plugins/aws/deploy/lib/ensure-valid-bucket-exists.js @@ -3,6 +3,11 @@ const ServerlessError = require('../../../../serverless-error'); const { log, progress } = require('../../../../utils/serverless-utils/log'); const jsyaml = require('js-yaml'); +const { S3Client, HeadBucketCommand } = require('@aws-sdk/client-s3'); +const { + getS3BucketRegion, + isCloudFormationValidationError, +} = require('../../utils/aws-sdk-v3-error'); const mainProgress = progress.get('main'); @@ -16,7 +21,7 @@ module.exports = { // If there is a validation error with expected message, it means that logical resource for // S3 bucket does not exist and we want to proceed with handling that situation if ( - err.providerError.code !== 'ValidationError' || + !isCloudFormationValidationError(err) || !err.message.includes('does not exist for stack') ) { throw err; @@ -25,20 +30,27 @@ module.exports = { // Validate that custom deployment bucket exists and has proper location if (this.serverless.service.provider.deploymentBucket) { - let result; + let bucketRegion; try { - result = await this.provider.request('S3', 'headBucket', { - Bucket: this.bucketName, - }); + const s3 = new S3Client(await this.provider.getAwsSdkV3Config()); + bucketRegion = getS3BucketRegion( + await s3.send(new HeadBucketCommand({ Bucket: this.bucketName })) + ); } catch (err) { + bucketRegion = getS3BucketRegion(err); + if (bucketRegion && bucketRegion !== this.provider.getRegion()) { + throw new ServerlessError( + 'Deployment bucket is not in the same region as the lambda function', + 'DEPLOYMENT_BUCKET_INVALID_REGION' + ); + } throw new ServerlessError( `Could not locate deployment bucket: "${this.bucketName}". Error: ${err.message}`, 'DEPLOYMENT_BUCKET_NOT_FOUND' ); } - if (result.BucketRegion === 'EU') result.BucketRegion = 'eu-west-1'; - if (result.BucketRegion !== this.provider.getRegion()) { + if (bucketRegion !== this.provider.getRegion()) { throw new ServerlessError( 'Deployment bucket is not in the same region as the lambda function', 'DEPLOYMENT_BUCKET_INVALID_REGION' diff --git a/lib/plugins/aws/deploy/lib/validate-template.js b/lib/plugins/aws/deploy/lib/validate-template.js index 405f4a9c4..d6bf57228 100644 --- a/lib/plugins/aws/deploy/lib/validate-template.js +++ b/lib/plugins/aws/deploy/lib/validate-template.js @@ -2,6 +2,7 @@ const getS3EndpointForRegion = require('../../utils/get-s3-endpoint-for-region'); const ServerlessError = require('../../../../serverless-error'); +const { CloudFormationClient, ValidateTemplateCommand } = require('@aws-sdk/client-cloudformation'); module.exports = { async validateTemplate() { @@ -13,7 +14,8 @@ module.exports = { TemplateURL: `https://${s3Endpoint}/${bucketName}/${artifactDirectoryName}/${compiledTemplateFileName}`, }; - return this.provider.request('CloudFormation', 'validateTemplate', params).catch((error) => { + const cloudFormation = new CloudFormationClient(await this.provider.getAwsSdkV3Config()); + return cloudFormation.send(new ValidateTemplateCommand(params)).catch((error) => { const errorMessage = ['The CloudFormation template is invalid:', ` ${error.message}`].join( '' ); diff --git a/lib/plugins/aws/info/get-api-key-values.js b/lib/plugins/aws/info/get-api-key-values.js index 827f7252d..2f617440d 100644 --- a/lib/plugins/aws/info/get-api-key-values.js +++ b/lib/plugins/aws/info/get-api-key-values.js @@ -1,6 +1,14 @@ 'use strict'; +const promiseLimit = require('ext/promise/limit').bind(Promise); const isObject = require('type/object/is'); +const { + CloudFormationClient, + DescribeStackResourcesCommand, +} = require('@aws-sdk/client-cloudformation'); +const { APIGatewayClient, GetApiKeyCommand } = require('@aws-sdk/client-api-gateway'); + +const limitApiGatewayRequests = promiseLimit(2, async (task) => task()); module.exports = { async getApiKeyValues() { @@ -31,34 +39,38 @@ module.exports = { } if (apiKeyNames.length) { - return this.provider - .request('CloudFormation', 'describeStackResources', { + const config = await this.provider.getAwsSdkV3Config(); + const cloudFormation = new CloudFormationClient(config); + const resources = await cloudFormation.send( + new DescribeStackResourcesCommand({ StackName: this.provider.naming.getStackName(), }) - .then((resources) => { - const apiKeys = (resources.StackResources || []) - .filter((resource) => resource.ResourceType === 'AWS::ApiGateway::ApiKey') - .map((resource) => resource.PhysicalResourceId); - return Promise.all( - apiKeys.map((apiKey) => - this.provider.request('APIGateway', 'getApiKey', { + ); + const apiKeys = (resources.StackResources || []) + .filter((resource) => resource.ResourceType === 'AWS::ApiGateway::ApiKey') + .map((resource) => resource.PhysicalResourceId); + const apiGateway = new APIGatewayClient(config); + const apiKeyResults = await Promise.all( + apiKeys.map((apiKey) => + limitApiGatewayRequests(() => + apiGateway.send( + new GetApiKeyCommand({ apiKey, includeValue: true, }) ) - ); - }) - .then((apiKeys) => { - if (apiKeys && apiKeys.length) { - info.apiKeys = apiKeys.map((apiKey) => ({ - name: apiKey.name, - value: apiKey.value, - description: apiKey.description, - customerId: apiKey.customerId, - })); - } - return undefined; - }); + ) + ) + ); + if (apiKeyResults && apiKeyResults.length) { + info.apiKeys = apiKeyResults.map((apiKey) => ({ + name: apiKey.name, + value: apiKey.value, + description: apiKey.description, + customerId: apiKey.customerId, + })); + } + return undefined; } return undefined; }, diff --git a/lib/plugins/aws/info/get-resource-count.js b/lib/plugins/aws/info/get-resource-count.js index a7e2630e3..fa34cc40e 100644 --- a/lib/plugins/aws/info/get-resource-count.js +++ b/lib/plugins/aws/info/get-resource-count.js @@ -1,19 +1,24 @@ 'use strict'; +const { + CloudFormationClient, + ListStackResourcesCommand, +} = require('@aws-sdk/client-cloudformation'); + module.exports = { async getResourceCount(nextToken, resourceCount = 0) { const params = { StackName: this.provider.naming.getStackName(), NextToken: nextToken, }; - return this.provider.request('CloudFormation', 'listStackResources', params).then((result) => { - if (Object.keys(result).length) { - this.gatheredData.info.resourceCount = resourceCount + result.StackResourceSummaries.length; - if (result.NextToken) { - return this.getResourceCount(result.NextToken, this.gatheredData.info.resourceCount); - } + const cloudFormation = new CloudFormationClient(await this.provider.getAwsSdkV3Config()); + const result = await cloudFormation.send(new ListStackResourcesCommand(params)); + if (Object.keys(result).length) { + this.gatheredData.info.resourceCount = resourceCount + result.StackResourceSummaries.length; + if (result.NextToken) { + return this.getResourceCount(result.NextToken, this.gatheredData.info.resourceCount); } - return undefined; - }); + } + return undefined; }, }; diff --git a/lib/plugins/aws/info/get-stack-info.js b/lib/plugins/aws/info/get-stack-info.js index 50de98ace..1bc452e21 100644 --- a/lib/plugins/aws/info/get-stack-info.js +++ b/lib/plugins/aws/info/get-stack-info.js @@ -2,6 +2,8 @@ const resolveCfImportValue = require('../utils/resolve-cf-import-value'); const ServerlessError = require('../../../serverless-error'); +const { CloudFormationClient, DescribeStacksCommand } = require('@aws-sdk/client-cloudformation'); +const { ApiGatewayV2Client, GetApiCommand } = require('@aws-sdk/client-apigatewayv2'); module.exports = { async getStackInfo() { @@ -22,12 +24,12 @@ module.exports = { const stackName = this.provider.naming.getStackName(); const stackData = {}; + const config = await this.provider.getAwsSdkV3Config(); + const cloudFormation = new CloudFormationClient(config); const sdkRequests = [ - this.provider - .request('CloudFormation', 'describeStacks', { StackName: stackName }) - .then((result) => { - if (result) stackData.outputs = result.Stacks[0].Outputs; - }), + cloudFormation.send(new DescribeStacksCommand({ StackName: stackName })).then((result) => { + if (result) stackData.outputs = result.Stacks[0].Outputs; + }), ]; const httpApiId = this.serverless.service.provider.httpApi && this.serverless.service.provider.httpApi.id; @@ -38,7 +40,8 @@ module.exports = { : Promise.resolve(httpApiId) ) .then((id) => { - return this.provider.request('ApiGatewayV2', 'getApi', { ApiId: id }); + const apiGatewayV2 = new ApiGatewayV2Client(config); + return apiGatewayV2.send(new GetApiCommand({ ApiId: id })); }) .then( (result) => { diff --git a/lib/plugins/aws/lib/check-if-bucket-exists.js b/lib/plugins/aws/lib/check-if-bucket-exists.js index 6fc07164b..c25d44604 100644 --- a/lib/plugins/aws/lib/check-if-bucket-exists.js +++ b/lib/plugins/aws/lib/check-if-bucket-exists.js @@ -1,23 +1,27 @@ 'use strict'; const ServerlessError = require('../../../serverless-error'); +const { S3Client, HeadBucketCommand } = require('@aws-sdk/client-s3'); +const { + isS3HeadBucketNotFoundError, + isS3HeadBucketForbiddenError, +} = require('../utils/aws-sdk-v3-error'); module.exports = { async checkIfBucketExists(bucketName) { try { - await this.provider.request('S3', 'headBucket', { - Bucket: bucketName, - }); + const s3 = new S3Client(await this.provider.getAwsSdkV3Config()); + await s3.send(new HeadBucketCommand({ Bucket: bucketName })); return true; } catch (err) { - if (err.code === 'AWS_S3_HEAD_BUCKET_NOT_FOUND') { + if (isS3HeadBucketNotFoundError(err)) { return false; } - if (err.code === 'AWS_S3_HEAD_BUCKET_FORBIDDEN') { + if (isS3HeadBucketForbiddenError(err)) { throw new ServerlessError( 'Could not access the deployment bucket. Make sure you have sufficient permissions to access it.', - err.code + 'AWS_S3_HEAD_BUCKET_FORBIDDEN' ); } diff --git a/lib/plugins/aws/lib/check-if-ecr-repository-exists.js b/lib/plugins/aws/lib/check-if-ecr-repository-exists.js index 124982219..20602d963 100644 --- a/lib/plugins/aws/lib/check-if-ecr-repository-exists.js +++ b/lib/plugins/aws/lib/check-if-ecr-repository-exists.js @@ -1,22 +1,30 @@ 'use strict'; const { log } = require('../../../utils/serverless-utils/log'); +const { ECRClient, DescribeRepositoriesCommand } = require('@aws-sdk/client-ecr'); +const { + isEcrRepositoryNotFoundError, + isEcrAccessDeniedError, +} = require('../utils/aws-sdk-v3-error'); module.exports = { async checkIfEcrRepositoryExists() { const registryId = await this.provider.getAccountId(); const repositoryName = this.provider.naming.getEcrRepositoryName(); try { - await this.provider.request('ECR', 'describeRepositories', { - repositoryNames: [repositoryName], - registryId, - }); + const ecr = new ECRClient(await this.provider.getAwsSdkV3Config()); + await ecr.send( + new DescribeRepositoriesCommand({ + repositoryNames: [repositoryName], + registryId, + }) + ); return true; } catch (err) { - if (err.providerError && err.providerError.code === 'RepositoryNotFoundException') { + if (isEcrRepositoryNotFoundError(err)) { return false; } - if (err.providerError && err.providerError.code === 'AccessDeniedException') { + if (isEcrAccessDeniedError(err)) { if (this.serverless.service.provider.ecr && this.serverless.service.provider.ecr.images) { log.warning( 'Could not access ECR repository due to denied access, but there are images defined in "provider.ecr". ECR repository removal will be skipped.' diff --git a/lib/plugins/aws/logs.js b/lib/plugins/aws/logs.js index 55e980e79..7bee20fa3 100644 --- a/lib/plugins/aws/logs.js +++ b/lib/plugins/aws/logs.js @@ -7,6 +7,11 @@ const { writeText } = require('../../utils/serverless-utils/log'); const validate = require('./lib/validate'); const formatLambdaLogEvent = require('./utils/format-lambda-log-event'); const ServerlessError = require('../../serverless-error'); +const { + CloudWatchLogsClient, + DescribeLogStreamsCommand, + FilterLogEventsCommand, +} = require('@aws-sdk/client-cloudwatch-logs'); dayjs.extend(utc); @@ -45,8 +50,9 @@ class AwsLogs { orderBy: 'LastEventTime', }; - const reply = await this.provider.request('CloudWatchLogs', 'describeLogStreams', params); - if (!reply || reply.logStreams.length === 0) { + const cloudWatchLogs = new CloudWatchLogsClient(await this.provider.getAwsSdkV3Config()); + const reply = await cloudWatchLogs.send(new DescribeLogStreamsCommand(params)); + if (!reply || !reply.logStreams || reply.logStreams.length === 0) { throw new ServerlessError('No existing streams for the function', 'NO_EXISTING_LOG_STREAMS'); } @@ -91,7 +97,8 @@ class AwsLogs { } } - const results = await this.provider.request('CloudWatchLogs', 'filterLogEvents', params); + const cloudWatchLogs = new CloudWatchLogsClient(await this.provider.getAwsSdkV3Config()); + const results = await cloudWatchLogs.send(new FilterLogEventsCommand(params)); if (results.events) { results.events.forEach((e) => { if (e.message.includes('SERVERLESS_ENTERPRISE') || e.message.startsWith('END')) { diff --git a/lib/plugins/aws/metrics.js b/lib/plugins/aws/metrics.js index b2b5d47eb..34bf4ba2a 100644 --- a/lib/plugins/aws/metrics.js +++ b/lib/plugins/aws/metrics.js @@ -1,13 +1,17 @@ 'use strict'; +const promiseLimit = require('ext/promise/limit').bind(Promise); const dayjs = require('dayjs'); const validate = require('./lib/validate'); const { writeText, style } = require('../../utils/serverless-utils/log'); +const { CloudWatchClient, GetMetricStatisticsCommand } = require('@aws-sdk/client-cloudwatch'); const LocalizedFormat = require('dayjs/plugin/localizedFormat'); dayjs.extend(LocalizedFormat); +const limitCloudWatchRequests = promiseLimit(6, async (task) => task()); + class AwsMetrics { constructor(serverless, options) { this.serverless = serverless; @@ -51,6 +55,7 @@ class AwsMetrics { const functions = this.options.function ? [this.serverless.service.getFunction(this.options.function).name] : this.serverless.service.getAllFunctionsNames(); + const cloudWatch = new CloudWatchClient(await this.provider.getAwsSdkV3Config()); return Promise.all( functions.map(async (functionName) => { @@ -84,7 +89,7 @@ class AwsMetrics { }); const getMetrics = (params) => - this.provider.request('CloudWatch', 'getMetricStatistics', params); + limitCloudWatchRequests(() => cloudWatch.send(new GetMetricStatisticsCommand(params))); return Promise.all([ getMetrics(invocationsParams), diff --git a/lib/plugins/aws/provider.js b/lib/plugins/aws/provider.js index fad880824..de187b666 100644 --- a/lib/plugins/aws/provider.js +++ b/lib/plugins/aws/provider.js @@ -1,6 +1,11 @@ 'use strict'; const AWS = require('../../aws/sdk-v2'); +const { + CloudFormationClient, + DescribeStackResourceCommand, +} = require('@aws-sdk/client-cloudformation'); +const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts'); const naming = require('./lib/naming.js'); const fsp = require('fs').promises; const getS3EndpointForRegion = require('./utils/get-s3-endpoint-for-region'); @@ -1800,10 +1805,24 @@ class AwsProvider { const cacheKey = getAwsSdkV3CredentialsProviderCacheKey({ provider: this, ...options }); if (!this.awsSdkV3CredentialsProviders.has(cacheKey)) { - this.awsSdkV3CredentialsProviders.set( - cacheKey, - getAwsSdkV3CredentialsProvider({ provider: this, ...options }) - ); + const credentialsProvider = getAwsSdkV3CredentialsProvider({ provider: this, ...options }); + this.awsSdkV3CredentialsProviders.set(cacheKey, async (providerOptions) => { + try { + return await credentialsProvider(providerOptions); + } catch (error) { + if ( + error && + error.message && + error.message.includes('Could not load credentials from any providers') + ) { + throw new ServerlessError( + 'AWS provider credentials not found.', + 'AWS_CREDENTIALS_NOT_FOUND' + ); + } + throw error; + } + }); } return this.awsSdkV3CredentialsProviders.get(cacheKey); @@ -1942,10 +1961,14 @@ class AwsProvider { if (this.serverless.service.provider.deploymentBucket) { return this.serverless.service.provider.deploymentBucket; } - return this.request('CloudFormation', 'describeStackResource', { - StackName: this.naming.getStackName(), - LogicalResourceId: this.naming.getDeploymentBucketLogicalId(), - }).then((result) => result.StackResourceDetail.PhysicalResourceId); + const cloudFormation = new CloudFormationClient(await this.getAwsSdkV3Config()); + const result = await cloudFormation.send( + new DescribeStackResourceCommand({ + StackName: this.naming.getStackName(), + LogicalResourceId: this.naming.getDeploymentBucketLogicalId(), + }) + ); + return result.StackResourceDetail.PhysicalResourceId; } getDeploymentPrefix() { @@ -2202,7 +2225,8 @@ Object.defineProperties( memoizeeMethods({ getAccountInfo: d( async function () { - const result = await this.request('STS', 'getCallerIdentity', {}); + const sts = new STSClient(await this.getAwsSdkV3Config()); + const result = await sts.send(new GetCallerIdentityCommand({})); const arn = result.Arn; const accountId = result.Account; const partition = arn.split(':')[1]; // ex: arn:aws:iam:acctId:user/xyz diff --git a/lib/plugins/aws/remove/lib/bucket.js b/lib/plugins/aws/remove/lib/bucket.js index 7fd9b4af6..1f4310884 100644 --- a/lib/plugins/aws/remove/lib/bucket.js +++ b/lib/plugins/aws/remove/lib/bucket.js @@ -3,6 +3,7 @@ const { log } = require('../../../../utils/serverless-utils/log'); const ServerlessError = require('../../../../serverless-error'); const isS3ListAccessDeniedError = require('../../utils/is-s3-list-access-denied-error'); +const { isCloudFormationValidationError } = require('../../utils/aws-sdk-v3-error'); const maxDeleteObjectsCount = 1000; @@ -21,8 +22,8 @@ module.exports = { // If there is a validation error with expected message, it means that logical resource for // S3 bucket does not exist and we want to proceed with empty `bucketName` if ( - err.providerError.code !== 'ValidationError' || - !err.message.includes('does not exist for stack') + !isCloudFormationValidationError(err) || + !(err.message && err.message.includes('does not exist for stack')) ) { throw err; } diff --git a/lib/plugins/aws/utils/aws-sdk-v3-error.js b/lib/plugins/aws/utils/aws-sdk-v3-error.js new file mode 100644 index 000000000..8aa03ba4c --- /dev/null +++ b/lib/plugins/aws/utils/aws-sdk-v3-error.js @@ -0,0 +1,131 @@ +'use strict'; + +const isS3ListAccessDeniedError = require('./is-s3-list-access-denied-error'); + +function getAwsErrorName(error) { + if (!error || error.name === 'Error') return undefined; + return error.name; +} + +function getAwsErrorCode(error) { + if (!error) return undefined; + if (error.providerError) { + const providerErrorCode = + error.providerError.code || error.providerError.Code || getAwsErrorName(error.providerError); + if (providerErrorCode) return providerErrorCode; + } + if (error.Code) return error.Code; + if (error.code) return error.code; + return getAwsErrorName(error); +} + +function getAwsErrorStatusCode(error) { + return ( + error && + ((error.providerError && error.providerError.statusCode) || + (error.$metadata && error.$metadata.httpStatusCode) || + error.statusCode) + ); +} + +function isAwsErrorCode(error, ...codes) { + return codes.includes(getAwsErrorCode(error)); +} + +function isAwsErrorStatusCode(error, ...statusCodes) { + return statusCodes.includes(getAwsErrorStatusCode(error)); +} + +function getHeader(headers, headerName) { + if (!headers) return undefined; + if (headers[headerName]) return headers[headerName]; + const normalizedHeaderName = headerName.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === normalizedHeaderName) return value; + } + return undefined; +} + +function normalizeS3BucketRegion(region) { + if (region === 'EU') return 'eu-west-1'; + return region; +} + +function getS3BucketRegion(resultOrError) { + if (!resultOrError) return undefined; + const metadataHeaders = resultOrError.$metadata && resultOrError.$metadata.httpHeaders; + const responseHeaders = resultOrError.$response && resultOrError.$response.headers; + return normalizeS3BucketRegion( + resultOrError.BucketRegion || + resultOrError.bucketRegion || + resultOrError.region || + getHeader(metadataHeaders, 'x-amz-bucket-region') || + getHeader(responseHeaders, 'x-amz-bucket-region') + ); +} + +function isS3ListObjectsNoSuchBucketError(error) { + return ( + isAwsErrorCode(error, 'AWS_S3_LIST_OBJECTS_V2_NO_SUCH_BUCKET', 'NoSuchBucket') || + isAwsErrorStatusCode(error, 404) || + (error && error.message && error.message.includes('The specified bucket does not exist')) + ); +} + +function isS3ListObjectsAccessDeniedError(error) { + return isS3ListAccessDeniedError(error); +} + +function isS3HeadObjectForbiddenError(error) { + return ( + isAwsErrorCode(error, 'AWS_S3_HEAD_OBJECT_FORBIDDEN', 'Forbidden', 'AccessDenied') || + isAwsErrorStatusCode(error, 403) + ); +} + +function isS3HeadBucketNotFoundError(error) { + return ( + isAwsErrorCode(error, 'AWS_S3_HEAD_BUCKET_NOT_FOUND', 'NotFound', 'NoSuchBucket') || + isAwsErrorStatusCode(error, 404) + ); +} + +function isS3HeadBucketForbiddenError(error) { + return ( + isAwsErrorCode(error, 'AWS_S3_HEAD_BUCKET_FORBIDDEN', 'Forbidden', 'AccessDenied') || + isAwsErrorStatusCode(error, 403) + ); +} + +function isCloudFormationValidationError(error) { + return isAwsErrorCode(error, 'ValidationError'); +} + +function isLambdaAccessDeniedError(error) { + return isAwsErrorCode(error, 'AccessDeniedException') || isAwsErrorStatusCode(error, 403); +} + +function isEcrRepositoryNotFoundError(error) { + return isAwsErrorCode(error, 'RepositoryNotFoundException'); +} + +function isEcrAccessDeniedError(error) { + return isAwsErrorCode(error, 'AccessDeniedException'); +} + +module.exports = { + getAwsErrorCode, + getAwsErrorStatusCode, + getS3BucketRegion, + isAwsErrorCode, + isAwsErrorStatusCode, + isS3ListObjectsNoSuchBucketError, + isS3ListObjectsAccessDeniedError, + isS3HeadObjectForbiddenError, + isS3HeadBucketNotFoundError, + isS3HeadBucketForbiddenError, + isCloudFormationValidationError, + isLambdaAccessDeniedError, + isEcrRepositoryNotFoundError, + isEcrAccessDeniedError, +}; diff --git a/lib/plugins/aws/utils/resolve-cf-import-value.js b/lib/plugins/aws/utils/resolve-cf-import-value.js index 78baba0d1..15684e5e5 100644 --- a/lib/plugins/aws/utils/resolve-cf-import-value.js +++ b/lib/plugins/aws/utils/resolve-cf-import-value.js @@ -1,20 +1,21 @@ 'use strict'; const ServerlessError = require('../../../serverless-error'); +const { CloudFormationClient, ListExportsCommand } = require('@aws-sdk/client-cloudformation'); async function resolveCfImportValue(provider, name, sdkParams = {}) { - return provider.request('CloudFormation', 'listExports', sdkParams).then((result) => { - const targetExportMeta = result.Exports.find((exportMeta) => exportMeta.Name === name); - if (targetExportMeta) return targetExportMeta.Value; - if (result.NextToken) { - return resolveCfImportValue(provider, name, { NextToken: result.NextToken }); - } + const cloudFormation = new CloudFormationClient(await provider.getAwsSdkV3Config()); + const result = await cloudFormation.send(new ListExportsCommand(sdkParams)); + const targetExportMeta = result.Exports.find((exportMeta) => exportMeta.Name === name); + if (targetExportMeta) return targetExportMeta.Value; + if (result.NextToken) { + return resolveCfImportValue(provider, name, { NextToken: result.NextToken }); + } - throw new ServerlessError( - `Could not resolve Fn::ImportValue with name ${name}. Are you sure this value is exported ?`, - 'CF_IMPORT_RESOLUTION' - ); - }); + throw new ServerlessError( + `Could not resolve Fn::ImportValue with name ${name}. Are you sure this value is exported ?`, + 'CF_IMPORT_RESOLUTION' + ); } module.exports = resolveCfImportValue; diff --git a/package.json b/package.json index 1f64e0d44..d63e5a86c 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,12 @@ "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.3.5", "@aws-sdk/client-api-gateway": "^3.975.0", + "@aws-sdk/client-apigatewayv2": "^3.975.0", "@aws-sdk/client-cloudformation": "^3.975.0", + "@aws-sdk/client-cloudwatch": "^3.975.0", + "@aws-sdk/client-cloudwatch-logs": "^3.975.0", "@aws-sdk/client-cognito-identity-provider": "^3.975.0", + "@aws-sdk/client-ecr": "^3.975.0", "@aws-sdk/client-eventbridge": "^3.975.0", "@aws-sdk/client-iam": "^3.975.0", "@aws-sdk/client-lambda": "^3.975.0", diff --git a/test/lib/configure-aws-sdk-v3-stub.js b/test/lib/configure-aws-sdk-v3-stub.js new file mode 100644 index 000000000..c9ba0d898 --- /dev/null +++ b/test/lib/configure-aws-sdk-v3-stub.js @@ -0,0 +1,227 @@ +'use strict'; + +const ensurePlainObject = require('type/plain-object/ensure'); + +const hasOwn = Object.prototype.hasOwnProperty.call.bind(Object.prototype.hasOwnProperty); + +const serviceDefinitions = { + APIGateway: { + packageName: '@aws-sdk/client-api-gateway', + clientName: 'APIGatewayClient', + commands: { + getApiKey: 'GetApiKeyCommand', + }, + }, + ApiGatewayV2: { + packageName: '@aws-sdk/client-apigatewayv2', + clientName: 'ApiGatewayV2Client', + commands: { + getApi: 'GetApiCommand', + }, + }, + CloudFormation: { + packageName: '@aws-sdk/client-cloudformation', + clientName: 'CloudFormationClient', + commands: { + describeStacks: 'DescribeStacksCommand', + describeStackResources: 'DescribeStackResourcesCommand', + listStackResources: 'ListStackResourcesCommand', + describeStackResource: 'DescribeStackResourceCommand', + validateTemplate: 'ValidateTemplateCommand', + listExports: 'ListExportsCommand', + }, + }, + CloudWatch: { + packageName: '@aws-sdk/client-cloudwatch', + clientName: 'CloudWatchClient', + commands: { + getMetricStatistics: 'GetMetricStatisticsCommand', + }, + }, + CloudWatchLogs: { + packageName: '@aws-sdk/client-cloudwatch-logs', + clientName: 'CloudWatchLogsClient', + commands: { + describeLogStreams: 'DescribeLogStreamsCommand', + filterLogEvents: 'FilterLogEventsCommand', + describeSubscriptionFilters: 'DescribeSubscriptionFiltersCommand', + }, + }, + ECR: { + packageName: '@aws-sdk/client-ecr', + clientName: 'ECRClient', + commands: { + describeRepositories: 'DescribeRepositoriesCommand', + }, + }, + Lambda: { + packageName: '@aws-sdk/client-lambda', + clientName: 'LambdaClient', + commands: { + getFunction: 'GetFunctionCommand', + listVersionsByFunction: 'ListVersionsByFunctionCommand', + }, + }, + S3: { + packageName: '@aws-sdk/client-s3', + clientName: 'S3Client', + commands: { + listObjectsV2: 'ListObjectsV2Command', + headObject: 'HeadObjectCommand', + headBucket: 'HeadBucketCommand', + }, + paginators: { + listObjectsV2: { + name: 'paginateListObjectsV2', + inputToken: 'ContinuationToken', + outputToken: 'NextContinuationToken', + }, + }, + }, + STS: { + packageName: '@aws-sdk/client-sts', + clientName: 'STSClient', + commands: { + getCallerIdentity: 'GetCallerIdentityCommand', + }, + }, +}; + +function getMethodByCommandName(definition, commandName) { + for (const [method, candidateCommandName] of Object.entries(definition.commands)) { + if (candidateCommandName === commandName) return method; + } + return null; +} + +function createNamedClass(name, constructor) { + Object.defineProperty(constructor, 'name', { value: name }); + return constructor; +} + +async function resolveStubValue({ state, service, method, value, input, context }) { + const callKey = `${service}.${method}`; + const callIndex = state.callCounts.get(callKey) || 0; + state.callCounts.set(callKey, callIndex + 1); + + if (Array.isArray(value)) { + value = value[Math.min(callIndex, value.length - 1)]; + } + + return typeof value === 'function' ? value(input, context) : value; +} + +function getMethodStub(stubMap, service, method) { + const serviceConfig = stubMap[service]; + if (!serviceConfig || !hasOwn(serviceConfig, method)) { + throw new Error(`Missing AWS SDK v3 stub configuration for ${service}.${method}`); + } + return serviceConfig[method]; +} + +function createModuleStub({ service, definition, stubMap, state }) { + const exports = {}; + + const Client = createNamedClass( + definition.clientName, + class { + constructor(config) { + this.config = config; + this.service = service; + state.clients.push({ service, config, client: this }); + } + + async send(command) { + const commandName = command.__awsSdkV3StubCommandName || command.constructor.name; + const method = getMethodByCommandName(definition, commandName); + if (!method) throw new Error(`Unsupported AWS SDK v3 stub command ${commandName}`); + + const input = command.input; + const context = { + service, + method, + commandName, + input, + clientConfig: this.config, + client: this, + command, + }; + state.sends.push(context); + + const value = getMethodStub(stubMap, service, method); + return resolveStubValue({ state, service, method, value, input, context }); + } + } + ); + exports[definition.clientName] = Client; + + for (const commandName of Object.values(definition.commands)) { + exports[commandName] = createNamedClass( + commandName, + class { + constructor(input) { + this.input = input; + this.__awsSdkV3StubCommandName = commandName; + } + } + ); + } + + for (const [method, paginator] of Object.entries(definition.paginators || {})) { + exports[paginator.name] = async function* paginate(config, input) { + if (!config || !config.client || typeof config.client.send !== 'function') { + throw new Error(`AWS SDK v3 stub paginator ${paginator.name} requires config.client.send`); + } + const Command = exports[definition.commands[method]]; + if (!Command) throw new Error(`Unsupported AWS SDK v3 stub paginator method ${method}`); + let nextToken = input && input[paginator.inputToken]; + do { + const pageInput = { ...input }; + if (nextToken) pageInput[paginator.inputToken] = nextToken; + else delete pageInput[paginator.inputToken]; + const page = await config.client.send(new Command(pageInput)); + yield page; + nextToken = page && page[paginator.outputToken]; + } while (nextToken); + }; + } + + return exports; +} + +module.exports = (stubMap, { ignoreUnsupportedServices = false } = {}) => { + stubMap = ensurePlainObject(stubMap, { + errorMessage: 'Expected `awsSdkV3StubMap` to be a plain object, received %v', + }); + + const state = { clients: [], sends: [], callCounts: new Map() }; + const modulesCacheStub = {}; + + for (const service of Object.keys(stubMap)) { + const definition = serviceDefinitions[service]; + if (!definition) { + if (ignoreUnsupportedServices) continue; + throw new Error(`Unsupported AWS SDK v3 stub service ${service}`); + } + if ( + ignoreUnsupportedServices && + !Object.keys(stubMap[service]).some( + (method) => definition.commands[method] || (definition.paginators || {})[method] + ) + ) { + continue; + } + modulesCacheStub[definition.packageName] = createModuleStub({ + service, + definition, + stubMap, + state, + }); + } + + return { + modulesCacheStub, + clients: state.clients, + sends: state.sends, + }; +}; diff --git a/test/lib/run-serverless.js b/test/lib/run-serverless.js index ee68c3cda..4bdb2a01e 100644 --- a/test/lib/run-serverless.js +++ b/test/lib/run-serverless.js @@ -17,6 +17,7 @@ const observeOutput = require('./observe-output'); const disableServerlessStatsRequests = require('./disable-serverless-stats-requests'); const provisionTmpDir = require('./provision-tmp-dir'); const configureAwsRequestStub = require('./configure-aws-request-stub'); +const configureAwsSdkV3Stub = require('./configure-aws-sdk-v3-stub'); const resolveServerless = async (serverlessPath, modulesCacheStub, callback) => { if (!modulesCacheStub) { @@ -55,6 +56,7 @@ module.exports = async ( serverlessPath, { awsRequestStubMap, + awsSdkV3StubMap, command, options, config, @@ -144,12 +146,37 @@ module.exports = async ( errorMessage: 'Expected `envWhitelist` to be a var names collection, received %v', }); awsRequestStubMap = ensurePlainObject(awsRequestStubMap, { isOptional: true }); + awsSdkV3StubMap = ensurePlainObject(awsSdkV3StubMap, { isOptional: true }); + if (modulesCacheStub) modulesCacheStub = { ...modulesCacheStub }; + let awsSdkV3Stub; + const effectiveAwsSdkV3StubMap = awsSdkV3StubMap || awsRequestStubMap; + if (effectiveAwsSdkV3StubMap) { + awsSdkV3Stub = configureAwsSdkV3Stub(effectiveAwsSdkV3StubMap, { + ignoreUnsupportedServices: !awsSdkV3StubMap, + }); + const sdkV3ModuleStubs = Object.entries(awsSdkV3Stub.modulesCacheStub); + if (sdkV3ModuleStubs.length) { + if (!modulesCacheStub) modulesCacheStub = {}; + for (const [moduleName, moduleStub] of sdkV3ModuleStubs) { + if (modulesCacheStub[moduleName]) { + throw new Error(`Duplicate module cache stub for ${moduleName}`); + } + modulesCacheStub[moduleName] = moduleStub; + } + } + } if (shouldStubSpawn) { if (!modulesCacheStub) modulesCacheStub = {}; modulesCacheStub[path.resolve(serverlessPath, 'lib/utils/spawn.js')] = sinon .stub() .resolves({}); } + if (modulesCacheStub) { + const httpsProxyAgentPath = require.resolve('https-proxy-agent'); + if (!modulesCacheStub[httpsProxyAgentPath]) { + modulesCacheStub[httpsProxyAgentPath] = require('https-proxy-agent'); + } + } const confirmedCwd = await resolveCwd({ cwd, config }); const resolveConfigurationPath = require( @@ -370,6 +397,7 @@ module.exports = async ( output, cfTemplate: serverless.service.provider.compiledCloudFormationTemplate, awsNaming: awsProvider && awsProvider.naming, + awsSdkV3Stub, }; } catch (error) { throw Object.assign(error, { diff --git a/test/unit/lib/plugins/aws/deploy-list.test.js b/test/unit/lib/plugins/aws/deploy-list.test.js index 6e5cc27d7..b9dbe19aa 100644 --- a/test/unit/lib/plugins/aws/deploy-list.test.js +++ b/test/unit/lib/plugins/aws/deploy-list.test.js @@ -1,10 +1,41 @@ 'use strict'; const sinon = require('sinon'); +const proxyquire = require('proxyquire'); const expect = require('chai').expect; const AwsDeployList = require('../../../../../lib/plugins/aws/deploy-list'); const AwsProvider = require('../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../lib/serverless'); +const { S3Client, ListObjectsV2Command } = require('@aws-sdk/client-s3'); +const { + LambdaClient, + GetFunctionCommand, + ListVersionsByFunctionCommand, +} = require('@aws-sdk/client-lambda'); + +function formatDeploymentDate(dateString) { + const date = new Date(Date.parse(dateString)); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, 0)}-${String( + date.getUTCDate() + ).padStart(2, 0)} ${String(date.getUTCHours()).padStart(2, 0)}:${String( + date.getUTCMinutes() + ).padStart(2, 0)}:${String(date.getUTCSeconds()).padStart(2, 0)} UTC`; +} + +async function releasePendingRequestsUntilSettled(pendingResolvers, promise) { + let isSettled = false; + promise + .finally(() => { + isSettled = true; + }) + .catch(() => {}); + while (!isSettled) { + await Promise.resolve(); + for (const resolve of pendingResolvers.splice(0)) resolve(); + await Promise.resolve(); + } + return promise; +} describe('AwsDeployList', () => { let serverless; @@ -28,21 +59,58 @@ describe('AwsDeployList', () => { }); describe('#listDeployments()', () => { + let writeTextStub; + let noticeStub; + + function getAwsDeployListWithLogStubs() { + writeTextStub = sinon.stub(); + noticeStub = sinon.stub(); + noticeStub.skip = sinon.stub(); + const AwsDeployListWithLogStubs = proxyquire('../../../../../lib/plugins/aws/deploy-list', { + '../../utils/serverless-utils/log': { + log: { notice: noticeStub }, + writeText: writeTextStub, + }, + }); + const deployList = new AwsDeployListWithLogStubs(serverless, { stage: 'dev' }); + deployList.bucketName = 'deployment-bucket'; + return deployList; + } + + afterEach(() => { + if (S3Client.prototype.send.restore) S3Client.prototype.send.restore(); + }); + it('should print no deployments in case there are none', async () => { const s3Response = { Contents: [], }; - const listObjectsStub = sinon.stub(awsDeployList.provider, 'request').resolves(s3Response); + const listObjectsStub = sinon.stub(S3Client.prototype, 'send').resolves(s3Response); await awsDeployList.listDeployments(); expect(listObjectsStub.calledOnce).to.be.equal(true); + expect(listObjectsStub.firstCall.args[0]).to.be.instanceOf(ListObjectsV2Command); + expect(listObjectsStub.firstCall.args[0].input).to.include({ + Bucket: awsDeployList.bucketName, + Prefix: `${s3Key}/`, + }); + }); + + it('should print no deployments in case paginated listings contain no deployments', async () => { + const deployList = getAwsDeployListWithLogStubs(); + sinon.stub(S3Client.prototype, 'send').resolves({ + Contents: [{ Key: `${s3Key}/not-a-deploy-dir/artifact.zip` }], + }); + + await deployList.listDeployments(); + + expect(writeTextStub.called).to.equal(false); + expect(noticeStub.calledOnce).to.equal(true); expect( - listObjectsStub.calledWithExactly('S3', 'listObjectsV2', { - Bucket: awsDeployList.bucketName, - Prefix: `${s3Key}/`, - }) - ).to.be.equal(true); - awsDeployList.provider.request.restore(); + noticeStub.skip.calledOnceWithExactly( + "No deployments found, if that's unexpected ensure that stage and region are correct" + ) + ).to.equal(true); }); it('should display all available deployments', async () => { @@ -55,17 +123,160 @@ describe('AwsDeployList', () => { ], }; - const listObjectsStub = sinon.stub(awsDeployList.provider, 'request').resolves(s3Response); + const listObjectsStub = sinon.stub(S3Client.prototype, 'send').resolves(s3Response); await awsDeployList.listDeployments(); expect(listObjectsStub.calledOnce).to.be.equal(true); - expect( - listObjectsStub.calledWithExactly('S3', 'listObjectsV2', { - Bucket: awsDeployList.bucketName, - Prefix: `${s3Key}/`, + expect(listObjectsStub.firstCall.args[0]).to.be.instanceOf(ListObjectsV2Command); + expect(listObjectsStub.firstCall.args[0].input).to.include({ + Bucket: awsDeployList.bucketName, + Prefix: `${s3Key}/`, + }); + }); + + it('should print a deployment directory split across paginated object listings as one group', async () => { + const deployList = getAwsDeployListWithLogStubs(); + sinon + .stub(S3Client.prototype, 'send') + .onFirstCall() + .resolves({ + Contents: [{ Key: `${s3Key}/113304333331-2016-08-18T13:40:06/artifact.zip` }], + NextContinuationToken: 'next-page', + }) + .onSecondCall() + .resolves({ + Contents: [ + { Key: `${s3Key}/113304333331-2016-08-18T13:40:06/cloudformation.json` }, + { Key: `${s3Key}/903940390431-2016-08-18T23:42:08/artifact.zip` }, + ], + }); + + await deployList.listDeployments(); + + expect(writeTextStub.firstCall.args).to.deep.equal([ + formatDeploymentDate('2016-08-18T13:40:06'), + 'Timestamp: 113304333331', + 'Files:', + ]); + expect(writeTextStub.secondCall.args).to.deep.equal([' - artifact.zip']); + expect(writeTextStub.thirdCall.args).to.deep.equal([' - cloudformation.json']); + expect(writeTextStub.getCall(3).args).to.deep.equal([ + formatDeploymentDate('2016-08-18T23:42:08'), + 'Timestamp: 903940390431', + 'Files:', + ]); + expect(writeTextStub.getCall(4).args).to.deep.equal([' - artifact.zip']); + expect(noticeStub.skip.called).to.equal(false); + }); + + it('should ignore unrelated paginated keys while printing deployments', async () => { + const deployList = getAwsDeployListWithLogStubs(); + sinon.stub(S3Client.prototype, 'send').resolves({ + Contents: [ + { Key: `${s3Key}/not-a-deploy-dir/artifact.zip` }, + { + Key: `other-prefix/${serverless.service.service}/dev/113304333331-2016-08-18T13:40:06/other.zip`, + }, + { Key: `${s3Key}/113304333331-2016-08-18T13:40:06/artifact.zip` }, + ], + }); + + await deployList.listDeployments(); + + expect(writeTextStub.calledTwice).to.equal(true); + expect(writeTextStub.secondCall.args).to.deep.equal([' - artifact.zip']); + expect(noticeStub.skip.called).to.equal(false); + }); + + it('should emit completed deployment output before fetching later pages', async () => { + const deployList = getAwsDeployListWithLogStubs(); + sinon + .stub(S3Client.prototype, 'send') + .onFirstCall() + .resolves({ + Contents: [{ Key: `${s3Key}/113304333331-2016-08-18T13:40:06/artifact.zip` }], + NextContinuationToken: 'second-page', + }) + .onSecondCall() + .resolves({ + Contents: [{ Key: `${s3Key}/903940390431-2016-08-18T23:42:08/artifact.zip` }], + NextContinuationToken: 'third-page', }) - ).to.be.equal(true); - awsDeployList.provider.request.restore(); + .onThirdCall() + .callsFake(async () => { + expect(writeTextStub.called).to.equal(true); + expect(writeTextStub.firstCall.args).to.deep.equal([ + formatDeploymentDate('2016-08-18T13:40:06'), + 'Timestamp: 113304333331', + 'Files:', + ]); + return { Contents: [] }; + }); + + await deployList.listDeployments(); + }); + + it('should display deployments across paginated object listings', async () => { + const listObjectsStub = sinon + .stub(S3Client.prototype, 'send') + .onFirstCall() + .resolves({ + Contents: [{ Key: `${s3Key}/113304333331-2016-08-18T13:40:06/artifact.zip` }], + NextContinuationToken: 'next-page', + }) + .onSecondCall() + .resolves({ + Contents: [{ Key: `${s3Key}/903940390431-2016-08-18T23:42:08/cloudformation.json` }], + }); + + await awsDeployList.listDeployments(); + + expect(listObjectsStub.calledTwice).to.equal(true); + expect(listObjectsStub.secondCall.args[0]).to.be.instanceOf(ListObjectsV2Command); + expect(listObjectsStub.secondCall.args[0].input).to.include({ + Bucket: awsDeployList.bucketName, + Prefix: `${s3Key}/`, + ContinuationToken: 'next-page', + }); + }); + + it('should translate S3 list access denied errors', async () => { + sinon.stub(S3Client.prototype, 'send').rejects({ $metadata: { httpStatusCode: 403 } }); + + try { + await awsDeployList.listDeployments(); + throw new Error('Expected listDeployments to reject'); + } catch (error) { + expect(error.code).to.equal('AWS_S3_LIST_OBJECTS_V2_ACCESS_DENIED'); + } + }); + + it('should preserve specific S3 list authentication failures', async () => { + const listError = new Error('signature mismatch'); + listError.providerError = { + code: 'SignatureDoesNotMatch', + statusCode: 403, + }; + sinon.stub(S3Client.prototype, 'send').rejects(listError); + + try { + await awsDeployList.listDeployments(); + throw new Error('Expected listDeployments to reject'); + } catch (error) { + expect(error).to.equal(listError); + } + }); + + it('should translate wrapped status-only S3 list access denied errors', async () => { + const listError = new Error('forbidden'); + listError.code = 'AWS_S3_LIST_OBJECTS_V2_ERROR'; + listError.providerError = { statusCode: 403 }; + sinon.stub(S3Client.prototype, 'send').rejects(listError); + + await expect(awsDeployList.listDeployments()).to.be.eventually.rejected.and.have.property( + 'code', + 'AWS_S3_LIST_OBJECTS_V2_ACCESS_DENIED' + ); }); }); @@ -107,21 +318,27 @@ describe('AwsDeployList', () => { name: 'listDeployments-dev-func2', }, }; - listFunctionsStub = sinon.stub(awsDeployList.provider, 'request'); - listFunctionsStub.onCall(0).resolves({ - Configuration: { - FunctionName: 'listDeployments-dev-func1', - }, - }); - listFunctionsStub.onCall(1).resolves({ - Configuration: { - FunctionName: 'listDeployments-dev-func2', - }, + listFunctionsStub = sinon.stub(LambdaClient.prototype, 'send').callsFake(async (command) => { + if (command.input.FunctionName === 'listDeployments-dev-func1') { + return { + Configuration: { + FunctionName: 'listDeployments-dev-func1', + }, + }; + } + if (command.input.FunctionName === 'listDeployments-dev-func2') { + return { + Configuration: { + FunctionName: 'listDeployments-dev-func2', + }, + }; + } + throw new Error(`Unexpected function lookup ${command.input.FunctionName}`); }); }); afterEach(() => { - awsDeployList.provider.request.restore(); + LambdaClient.prototype.send.restore(); }); it('should get all service related functions', async () => { @@ -133,14 +350,48 @@ describe('AwsDeployList', () => { const result = await awsDeployList.getFunctions(); expect(listFunctionsStub.callCount).to.equal(2); + for (const call of listFunctionsStub.getCalls()) { + expect(call.args[0]).to.be.instanceOf(GetFunctionCommand); + } + expect(listFunctionsStub.getCalls().map((call) => call.args[0].input)).to.deep.equal([ + { FunctionName: 'listDeployments-dev-func1' }, + { FunctionName: 'listDeployments-dev-func2' }, + ]); expect(result).to.deep.equal(expectedResult); }); + + it('limits concurrent Lambda getFunction requests to 6', async () => { + awsDeployList.serverless.service.functions = Object.fromEntries( + Array.from({ length: 10 }, (_, index) => [ + `func${index}`, + { name: `listDeployments-dev-func${index}` }, + ]) + ); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + listFunctionsStub.callsFake(async (command) => { + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(6); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { Configuration: { FunctionName: command.input.FunctionName } }; + }); + + const promise = awsDeployList.getFunctions(); + + await Promise.resolve(); + expect(observedMaxActiveRequests).to.equal(6); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(6); + }); }); describe('#getFunctionPaginatedVersions()', () => { beforeEach(() => { sinon - .stub(awsDeployList.provider, 'request') + .stub(LambdaClient.prototype, 'send') .onFirstCall() .resolves({ Versions: [{ FunctionName: 'listDeployments-dev-func', Version: '1' }], @@ -153,7 +404,7 @@ describe('AwsDeployList', () => { }); afterEach(() => { - awsDeployList.provider.request.restore(); + LambdaClient.prototype.send.restore(); }); it('should return the versions for the provided function when response is paginated', async () => { @@ -170,6 +421,16 @@ describe('AwsDeployList', () => { }; expect(result).to.deep.equal(expectedResult); + expect(LambdaClient.prototype.send.firstCall.args[0]).to.be.instanceOf( + ListVersionsByFunctionCommand + ); + expect(LambdaClient.prototype.send.firstCall.args[0].input).to.deep.equal({ + FunctionName: 'listDeployments-dev-func', + }); + expect(LambdaClient.prototype.send.secondCall.args[0].input).to.deep.equal({ + FunctionName: 'listDeployments-dev-func', + Marker: '123', + }); }); }); @@ -177,13 +438,13 @@ describe('AwsDeployList', () => { let listVersionsByFunctionStub; beforeEach(() => { - listVersionsByFunctionStub = sinon.stub(awsDeployList.provider, 'request').resolves({ + listVersionsByFunctionStub = sinon.stub(LambdaClient.prototype, 'send').resolves({ Versions: [{ FunctionName: 'listDeployments-dev-func', Version: '$LATEST' }], }); }); afterEach(() => { - awsDeployList.provider.request.restore(); + LambdaClient.prototype.send.restore(); }); it('should return the versions for the provided functions', async () => { @@ -203,7 +464,42 @@ describe('AwsDeployList', () => { ]; expect(listVersionsByFunctionStub.calledTwice).to.equal(true); + expect(listVersionsByFunctionStub.firstCall.args[0]).to.be.instanceOf( + ListVersionsByFunctionCommand + ); + expect(listVersionsByFunctionStub.getCalls().map((call) => call.args[0].input)).to.deep.equal( + [ + { FunctionName: 'listDeployments-dev-func1' }, + { FunctionName: 'listDeployments-dev-func2' }, + ] + ); expect(result).to.deep.equal(expectedResult); }); + + it('limits concurrent per-function version chains to 6', async () => { + const funcs = Array.from({ length: 10 }, (_, index) => ({ + FunctionName: `listDeployments-dev-func${index}`, + })); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + listVersionsByFunctionStub.callsFake(async (command) => { + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(6); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { + Versions: [{ FunctionName: command.input.FunctionName, Version: '$LATEST' }], + }; + }); + + const promise = awsDeployList.getFunctionVersions(funcs); + + await Promise.resolve(); + expect(observedMaxActiveRequests).to.equal(6); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(6); + }); }); }); diff --git a/test/unit/lib/plugins/aws/deploy/index.test.js b/test/unit/lib/plugins/aws/deploy/index.test.js index 80f874f3e..5b9bf28b8 100644 --- a/test/unit/lib/plugins/aws/deploy/index.test.js +++ b/test/unit/lib/plugins/aws/deploy/index.test.js @@ -1280,6 +1280,47 @@ describe('test/unit/lib/plugins/aws/deploy/index.test.js', () => { ); }); + it('with existing stack - with custom deployment bucket region redirect error', async () => { + const awsRequestStubMap = { + ...baseAwsRequestStubMap, + ECR: { + describeRepositories: sinon.stub().throws({ + providerError: { code: 'RepositoryNotFoundException' }, + }), + }, + S3: { + headBucket: () => { + const err = new Error('Moved Permanently'); + err.name = 'PermanentRedirect'; + err.$metadata = { httpStatusCode: 301 }; + err.$response = { headers: { 'x-amz-bucket-region': 'us-west-1' } }; + throw err; + }, + }, + CloudFormation: { + describeStacks: { Stacks: [{}] }, + validateTemplate: {}, + }, + }; + + await expect( + runServerless({ + fixture: 'function', + command: 'deploy', + awsRequestStubMap, + lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', + configExt: { + provider: { + deploymentBucket: 'bucket-name', + }, + }, + }) + ).to.eventually.have.been.rejected.and.have.property( + 'code', + 'DEPLOYMENT_BUCKET_INVALID_REGION' + ); + }); + it('with existing stack - with deployment bucket from CloudFormation deleted manually', async () => { const awsRequestStubMap = { ...baseAwsRequestStubMap, diff --git a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js index efe37aae1..93bfbdeec 100644 --- a/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js +++ b/test/unit/lib/plugins/aws/deploy/lib/check-for-changes.test.js @@ -12,12 +12,49 @@ const AwsDeploy = require('../../../../../../../lib/plugins/aws/deploy/index'); const Serverless = require('../../../../../../../lib/serverless'); const ServerlessError = require('../../../../../../../lib/serverless-error'); const runServerless = require('../../../../../../utils/run-serverless'); +const { S3Client, ListObjectsV2Command, HeadObjectCommand } = require('@aws-sdk/client-s3'); +const { LambdaClient, GetFunctionCommand } = require('@aws-sdk/client-lambda'); +const { + CloudWatchLogsClient, + DescribeSubscriptionFiltersCommand, +} = require('@aws-sdk/client-cloudwatch-logs'); +const { + CloudFormationClient, + DescribeStackResourceCommand, +} = require('@aws-sdk/client-cloudformation'); const fsp = fs.promises; // Configure chai const expect = require('chai').expect; +async function releasePendingRequestsUntilSettled(pendingResolvers, promise) { + let isSettled = false; + promise + .finally(() => { + isSettled = true; + }) + .catch(() => {}); + while (!isSettled) { + await Promise.resolve(); + for (const resolve of pendingResolvers.splice(0)) resolve(); + await Promise.resolve(); + } + return promise; +} + +function createAwsDeployTestInstance() { + const options = { + stage: 'dev', + region: 'us-east-1', + }; + const serverless = new Serverless({ commands: [], options: {} }); + const provider = new AwsProvider(serverless, options); + serverless.setProvider('aws', provider); + serverless.service.service = 'my-service'; + return new AwsDeploy(serverless, options); +} + describe('checkForChanges', () => { let serverless; let provider; @@ -112,11 +149,11 @@ describe('checkForChanges', () => { let listObjectsV2Stub; beforeEach(() => { - listObjectsV2Stub = sandbox.stub(awsDeploy.provider, 'request'); + listObjectsV2Stub = sandbox.stub(S3Client.prototype, 'send'); }); afterEach(() => { - awsDeploy.provider.request.restore(); + S3Client.prototype.send.restore(); }); it('should translate error if rejected due to missing bucket', () => { @@ -144,7 +181,8 @@ describe('checkForChanges', () => { listObjectsV2Stub.resolves(serviceObjects); return expect(awsDeploy.getMostRecentObjects()).to.be.fulfilled.then((result) => { - expect(listObjectsV2Stub).to.have.been.calledWithExactly('S3', 'listObjectsV2', { + expect(listObjectsV2Stub.firstCall.args[0]).to.be.instanceOf(ListObjectsV2Command); + expect(listObjectsV2Stub.firstCall.args[0].input).to.include({ Bucket: awsDeploy.bucketName, Prefix: 'serverless/my-service/dev/', }); @@ -165,7 +203,8 @@ describe('checkForChanges', () => { listObjectsV2Stub.resolves(serviceObjects); return expect(awsDeploy.getMostRecentObjects()).to.be.fulfilled.then((result) => { - expect(listObjectsV2Stub).to.have.been.calledWithExactly('S3', 'listObjectsV2', { + expect(listObjectsV2Stub.firstCall.args[0]).to.be.instanceOf(ListObjectsV2Command); + expect(listObjectsV2Stub.firstCall.args[0].input).to.include({ Bucket: awsDeploy.bucketName, Prefix: 'serverless/my-service/dev/', }); @@ -212,6 +251,80 @@ describe('checkForChanges', () => { { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, ]); }); + + it('should select the newest deployment directory across paginated results', async () => { + listObjectsV2Stub + .onFirstCall() + .resolves({ + Contents: [ + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/artifact.zip` }, + ], + NextContinuationToken: 'next-page', + }) + .onSecondCall() + .resolves({ + Contents: [ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + ], + }); + + const result = await awsDeploy.getMostRecentObjects(); + + expect(listObjectsV2Stub).to.have.been.calledTwice; + expect(listObjectsV2Stub.secondCall.args[0].input).to.include({ + ContinuationToken: 'next-page', + }); + expect(result).to.deep.equal([ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + ]); + }); + + it('should collect the latest deployment directory when it is split across pages', async () => { + listObjectsV2Stub + .onFirstCall() + .resolves({ + Contents: [ + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/artifact.zip` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + ], + NextContinuationToken: 'next-page', + }) + .onSecondCall() + .resolves({ + Contents: [ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json` }, + { Key: `${s3Key}/not-a-deploy-dir/ignored.zip` }, + ], + }); + + const result = await awsDeploy.getMostRecentObjects(); + + expect(result).to.deep.equal([ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + ]); + }); + + it('should discard older directories encountered after the latest directory', async () => { + listObjectsV2Stub.resolves({ + Contents: [ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/cloudformation.json` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json` }, + { Key: `${s3Key}/141264711231-2016-08-18T15:42:00/artifact.zip` }, + ], + }); + + const result = await awsDeploy.getMostRecentObjects(); + + expect(result).to.deep.equal([ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json` }, + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + ]); + }); }); describe('#getFunctionsEarliestLastModifiedDate()', () => { @@ -220,13 +333,13 @@ describe('checkForChanges', () => { let getFunctionStub; beforeEach(() => { - requestStub = sandbox.stub(awsDeploy.provider, 'request'); + requestStub = sandbox.stub(LambdaClient.prototype, 'send'); getAllFunctionsStub = sandbox.stub(awsDeploy.serverless.service, 'getAllFunctions'); getFunctionStub = sandbox.stub(awsDeploy.serverless.service, 'getFunction'); }); afterEach(() => { - awsDeploy.provider.request.restore(); + LambdaClient.prototype.send.restore(); awsDeploy.serverless.service.getAllFunctions.restore(); awsDeploy.serverless.service.getFunction.restore(); }); @@ -243,19 +356,45 @@ describe('checkForChanges', () => { const result = await awsDeploy.getFunctionsEarliestLastModifiedDate(); + expect(requestStub.firstCall.args[0]).to.be.instanceOf(GetFunctionCommand); + expect(requestStub.firstCall.args[0].input).to.deep.equal({ FunctionName: 'func-a' }); expect(result.toISOString()).to.equal(new Date('2021-05-19T15:34:16.494+0000').toISOString()); }); + + it('limits concurrent Lambda getFunction requests to 6', async () => { + const functionNames = Array.from({ length: 10 }, (_, index) => `func${index}`); + getAllFunctionsStub.returns(functionNames); + getFunctionStub.callsFake((functionName) => ({ name: functionName })); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + requestStub.callsFake(async () => { + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(6); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { Configuration: { LastModified: '2021-05-20T15:34:16.494+0000' } }; + }); + + const promise = awsDeploy.getFunctionsEarliestLastModifiedDate(); + + await Promise.resolve(); + expect(observedMaxActiveRequests).to.equal(6); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(6); + }); }); describe('#getObjectMetadata()', () => { let headObjectStub; beforeEach(() => { - headObjectStub = sandbox.stub(awsDeploy.provider, 'request').resolves({}); + headObjectStub = sandbox.stub(S3Client.prototype, 'send').resolves({}); }); afterEach(() => { - awsDeploy.provider.request.restore(); + S3Client.prototype.send.restore(); }); it('should resolve if no objects are provided as input', async () => { @@ -277,24 +416,52 @@ describe('checkForChanges', () => { return expect(awsDeploy.getObjectMetadata(input)).to.be.fulfilled.then(() => { expect(headObjectStub.callCount).to.equal(4); - expect(headObjectStub).to.have.been.calledWithExactly('S3', 'headObject', { - Bucket: awsDeploy.bucketName, - Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip`, - }); - expect(headObjectStub).to.have.been.calledWithExactly('S3', 'headObject', { - Bucket: awsDeploy.bucketName, - Key: `${s3Key}/151224711231-2016-08-18T15:43:00/cloudformation.json`, - }); - expect(headObjectStub).to.have.been.calledWithExactly('S3', 'headObject', { - Bucket: awsDeploy.bucketName, - Key: `${s3Key}/141264711231-2016-08-18T15:42:00/artifact.zip`, - }); - expect(headObjectStub).to.have.been.calledWithExactly('S3', 'headObject', { - Bucket: awsDeploy.bucketName, - Key: `${s3Key}/141264711231-2016-08-18T15:42:00/cloudformation.json`, - }); + for (const [index, { Key }] of input.entries()) { + expect(headObjectStub.getCall(index).args[0]).to.be.instanceOf(HeadObjectCommand); + expect(headObjectStub.getCall(index).args[0].input).to.deep.equal({ + Bucket: awsDeploy.bucketName, + Key, + }); + } }); }); + + it('should translate v3 forbidden errors', async () => { + headObjectStub.rejects({ $metadata: { httpStatusCode: 403 } }); + + try { + await awsDeploy.getObjectMetadata([ + { Key: `${s3Key}/151224711231-2016-08-18T15:43:00/artifact.zip` }, + ]); + throw new Error('Expected getObjectMetadata to reject'); + } catch (error) { + expect(error.code).to.equal('AWS_S3_HEAD_OBJECT_FORBIDDEN'); + } + }); + + it('limits concurrent S3 headObject requests to 6', async () => { + const input = Array.from({ length: 10 }, (_, index) => ({ + Key: `${s3Key}/151224711231-2016-08-18T15:43:00/file-${index}.zip`, + })); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + headObjectStub.callsFake(async () => { + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(6); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { Metadata: { filesha256: 'hash' } }; + }); + + const promise = awsDeploy.getObjectMetadata(input); + + await Promise.resolve(); + expect(observedMaxActiveRequests).to.equal(6); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(6); + }); }); describe('#checkIfDeploymentIsNecessary()', () => { @@ -705,6 +872,8 @@ const commonAwsSdkMock = { const generateMatchingListObjectsResponse = async (serverless) => { const packagePath = path.resolve(serverless.serviceDir, '.serverless'); + const provider = serverless.getProvider('aws'); + const deploymentBase = `${provider.getDeploymentPrefix()}/${serverless.service.service}/${provider.getStage()}`; const artifactNames = (await glob('*.zip', { cwd: packagePath })).map((filename) => path.basename(filename) ); @@ -712,11 +881,11 @@ const generateMatchingListObjectsResponse = async (serverless) => { return { Contents: [ { - Key: 'serverless/test-package-artifact/dev/code-artifacts/sls-otel.0.2.2.zip', + Key: `${deploymentBase}/code-artifacts/sls-otel.0.2.2.zip`, LastModified: new Date('2020-05-20T15:30:16.494+0000'), }, ...artifactNames.map((artifactName) => ({ - Key: `serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/${artifactName}`, + Key: `${deploymentBase}/1589988704359-2020-05-20T15:31:44.359Z/${artifactName}`, LastModified: new Date('2020-05-20T15:30:16.494+0000'), })), ], @@ -1180,12 +1349,16 @@ describe('test/unit/lib/plugins/aws/deploy/lib/checkForChanges.test.js', () => { }); it('Should gently handle error of accessing objects from S3 bucket', async () => { + let serverless; await expect( runServerless({ fixture: 'check-for-changes', command: 'deploy', lastLifecycleHookName: 'aws:deploy:deploy:checkForChanges', env: { AWS_CONTAINER_CREDENTIALS_FULL_URI: 'ignore' }, + hooks: { + beforeInstanceInit: (serverlessInstance) => (serverless = serverlessInstance), + }, awsRequestStubMap: { ...commonAwsSdkMock, S3: { @@ -1196,10 +1369,12 @@ describe('test/unit/lib/plugins/aws/deploy/lib/checkForChanges.test.js', () => { }, headBucket: () => {}, listObjectsV2: () => { + const provider = serverless.getProvider('aws'); + const deploymentBase = `${provider.getDeploymentPrefix()}/${serverless.service.service}/${provider.getStage()}`; return { Contents: [ { - Key: 'serverless/test-package-artifact/dev/1589988704359-2020-05-20T15:31:44.359Z/artifact.zip', + Key: `${deploymentBase}/1589988704359-2020-05-20T15:31:44.359Z/artifact.zip`, LastModified: new Date(), ETag: '"5102a4cf710cae6497dba9e61b85d0a4"', Size: 356, @@ -1215,6 +1390,104 @@ describe('test/unit/lib/plugins/aws/deploy/lib/checkForChanges.test.js', () => { }); describe('checkLogGroupSubscriptionFilterResourceLimitExceeded', () => { + it('limits concurrent CloudWatch Logs describeSubscriptionFilters requests to 2', async () => { + const awsDeploy = createAwsDeployTestInstance(); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + const describeSubscriptionFiltersStub = sandbox + .stub(CloudWatchLogsClient.prototype, 'send') + .callsFake(async (command) => { + expect(command).to.be.instanceOf(DescribeSubscriptionFiltersCommand); + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(2); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { subscriptionFilters: [] }; + }); + + try { + const promise = Promise.all( + Array.from({ length: 10 }, (_, index) => + awsDeploy.fixLogGroupSubscriptionFilters({ + accountId: '123456789012', + region: 'us-east-1', + partition: 'aws', + logGroupName: `log-group-${index}`, + cloudwatchLogEvents: [], + }) + ) + ); + + for (let index = 0; index < 10 && !pendingResolvers.length; index++) { + await Promise.resolve(); + } + expect(observedMaxActiveRequests).to.equal(2); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(2); + expect(describeSubscriptionFiltersStub).to.have.callCount(10); + } finally { + CloudWatchLogsClient.prototype.send.restore(); + } + }); + + it('limits concurrent CloudFormation describeStackResource requests to 6', async () => { + const awsDeploy = createAwsDeployTestInstance(); + const stackName = awsDeploy.provider.naming.getStackName(); + const logicalResourceId = awsDeploy.provider.naming.getCloudWatchLogLogicalId('Fn1', 1); + const filterName = `${stackName}-${logicalResourceId}-xxxxx`; + const cloudWatchLogsStub = sandbox.stub(CloudWatchLogsClient.prototype, 'send').resolves({ + subscriptionFilters: Array.from({ length: 10 }, () => ({ + filterName, + destinationArn: 'arn:aws:lambda:us-east-1:123456789012:function:service-dev-fn1', + })), + }); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + const describeStackResourceStub = sandbox + .stub(CloudFormationClient.prototype, 'send') + .callsFake(async (command) => { + expect(command).to.be.instanceOf(DescribeStackResourceCommand); + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(6); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { StackResourceDetail: { PhysicalResourceId: filterName } }; + }); + + try { + const promise = awsDeploy.fixLogGroupSubscriptionFilters({ + accountId: '123456789012', + region: 'us-east-1', + partition: 'aws', + logGroupName: 'someLogGroupName', + cloudwatchLogEvents: [ + { + FunctionName: 'service-dev-fn1', + functionName: 'Fn1', + logGroupName: 'someLogGroupName', + logSubscriptionSerialNumber: 1, + }, + ], + }); + + for (let index = 0; index < 10 && !pendingResolvers.length; index++) { + await Promise.resolve(); + } + expect(observedMaxActiveRequests).to.equal(6); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(6); + expect(cloudWatchLogsStub).to.have.been.calledOnce; + expect(describeStackResourceStub).to.have.callCount(10); + } finally { + CloudWatchLogsClient.prototype.send.restore(); + CloudFormationClient.prototype.send.restore(); + } + }); + it('does not crash when cloudwatchLog event uses __proto__ as the log group name', async () => { const deleteStub = sandbox.stub(); let serverless; diff --git a/test/unit/lib/plugins/aws/deploy/lib/validate-template.test.js b/test/unit/lib/plugins/aws/deploy/lib/validate-template.test.js index 144ad6192..395dfdda8 100644 --- a/test/unit/lib/plugins/aws/deploy/lib/validate-template.test.js +++ b/test/unit/lib/plugins/aws/deploy/lib/validate-template.test.js @@ -4,6 +4,7 @@ const sinon = require('sinon'); const AwsProvider = require('../../../../../../../lib/plugins/aws/provider'); const AwsDeploy = require('../../../../../../../lib/plugins/aws/deploy/index'); const Serverless = require('../../../../../../../lib/serverless'); +const { CloudFormationClient, ValidateTemplateCommand } = require('@aws-sdk/client-cloudformation'); // Configure chai const expect = require('chai').expect; @@ -29,11 +30,11 @@ describe('validateTemplate', () => { handler: 'foo', }, }; - validateTemplateStub = sinon.stub(awsDeploy.provider, 'request'); + validateTemplateStub = sinon.stub(CloudFormationClient.prototype, 'send'); }); afterEach(() => { - awsDeploy.provider.request.restore(); + CloudFormationClient.prototype.send.restore(); }); describe('#validateTemplate()', () => { @@ -42,14 +43,11 @@ describe('validateTemplate', () => { await awsDeploy.validateTemplate(); expect(validateTemplateStub).to.have.been.calledOnce; - expect(validateTemplateStub).to.have.been.calledWithExactly( - 'CloudFormation', - 'validateTemplate', - { - TemplateURL: - 'https://s3.amazonaws.com/deployment-bucket/somedir/compiled-cloudformation-template.json', - } - ); + expect(validateTemplateStub.firstCall.args[0]).to.be.instanceOf(ValidateTemplateCommand); + expect(validateTemplateStub.firstCall.args[0].input).to.deep.equal({ + TemplateURL: + 'https://s3.amazonaws.com/deployment-bucket/somedir/compiled-cloudformation-template.json', + }); }); it('should throw an error if the CloudFormation template is invalid', async () => { @@ -57,14 +55,11 @@ describe('validateTemplate', () => { return expect(awsDeploy.validateTemplate()).to.be.rejected.then((error) => { expect(validateTemplateStub).to.have.been.calledOnce; - expect(validateTemplateStub).to.have.been.calledWithExactly( - 'CloudFormation', - 'validateTemplate', - { - TemplateURL: - 'https://s3.amazonaws.com/deployment-bucket/somedir/compiled-cloudformation-template.json', - } - ); + expect(validateTemplateStub.firstCall.args[0]).to.be.instanceOf(ValidateTemplateCommand); + expect(validateTemplateStub.firstCall.args[0].input).to.deep.equal({ + TemplateURL: + 'https://s3.amazonaws.com/deployment-bucket/somedir/compiled-cloudformation-template.json', + }); expect(error.message).to.match(/is invalid: Some error while validating/); }); }); diff --git a/test/unit/lib/plugins/aws/info/get-api-key-values.test.js b/test/unit/lib/plugins/aws/info/get-api-key-values.test.js index 557eb8b42..071337fb1 100644 --- a/test/unit/lib/plugins/aws/info/get-api-key-values.test.js +++ b/test/unit/lib/plugins/aws/info/get-api-key-values.test.js @@ -5,11 +5,32 @@ const sinon = require('sinon'); const AwsInfo = require('../../../../../../lib/plugins/aws/info/index'); const AwsProvider = require('../../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../../lib/serverless'); +const { + CloudFormationClient, + DescribeStackResourcesCommand, +} = require('@aws-sdk/client-cloudformation'); +const { APIGatewayClient, GetApiKeyCommand } = require('@aws-sdk/client-api-gateway'); + +async function releasePendingRequestsUntilSettled(pendingResolvers, promise) { + let isSettled = false; + promise + .finally(() => { + isSettled = true; + }) + .catch(() => {}); + while (!isSettled) { + await Promise.resolve(); + for (const resolve of pendingResolvers.splice(0)) resolve(); + await Promise.resolve(); + } + return promise; +} describe('#getApiKeyValues()', () => { let serverless; let awsInfo; - let requestStub; + let cloudFormationSendStub; + let apiGatewaySendStub; beforeEach(() => { const options = { @@ -20,11 +41,13 @@ describe('#getApiKeyValues()', () => { serverless.setProvider('aws', new AwsProvider(serverless, options)); serverless.service.service = 'my-service'; awsInfo = new AwsInfo(serverless, options); - requestStub = sinon.stub(awsInfo.provider, 'request'); + cloudFormationSendStub = sinon.stub(CloudFormationClient.prototype, 'send'); + apiGatewaySendStub = sinon.stub(APIGatewayClient.prototype, 'send'); }); afterEach(() => { - awsInfo.provider.request.restore(); + CloudFormationClient.prototype.send.restore(); + APIGatewayClient.prototype.send.restore(); }); it('should add API Key values to this.gatheredData if API key names are available', async () => { @@ -37,7 +60,7 @@ describe('#getApiKeyValues()', () => { info: {}, }; - requestStub.onCall(0).resolves({ + cloudFormationSendStub.resolves({ StackResources: [ { PhysicalResourceId: 'giwn5zgpqj', @@ -54,14 +77,20 @@ describe('#getApiKeyValues()', () => { ], }); - requestStub.onCall(1).resolves({ id: 'giwn5zgpqj', value: 'valueForKeyFoo', name: 'foo' }); - - requestStub.onCall(2).resolves({ - id: 'e5wssvzmla', - value: 'valueForKeyBar', - name: 'bar', - description: 'bar description', - customerId: 'bar customer id', + apiGatewaySendStub.callsFake(async (command) => { + if (command.input.apiKey === 'giwn5zgpqj') { + return { id: 'giwn5zgpqj', value: 'valueForKeyFoo', name: 'foo' }; + } + if (command.input.apiKey === 'e5wssvzmla') { + return { + id: 'e5wssvzmla', + value: 'valueForKeyBar', + name: 'bar', + description: 'bar description', + customerId: 'bar customer id', + }; + } + throw new Error(`Unexpected API key lookup ${command.input.apiKey}`); }); const expectedGatheredDataObj = { @@ -84,11 +113,60 @@ describe('#getApiKeyValues()', () => { }; return awsInfo.getApiKeyValues().then(() => { - expect(requestStub.calledThrice).to.equal(true); + expect(cloudFormationSendStub).to.have.been.calledOnce; + expect(cloudFormationSendStub.firstCall.args[0]).to.be.instanceOf( + DescribeStackResourcesCommand + ); + expect(cloudFormationSendStub.firstCall.args[0].input).to.deep.equal({ + StackName: awsInfo.provider.naming.getStackName(), + }); + expect(apiGatewaySendStub).to.have.been.calledTwice; + for (const call of apiGatewaySendStub.getCalls()) { + expect(call.args[0]).to.be.instanceOf(GetApiKeyCommand); + } + expect(apiGatewaySendStub.getCalls().map((call) => call.args[0].input)).to.deep.equal([ + { apiKey: 'giwn5zgpqj', includeValue: true }, + { apiKey: 'e5wssvzmla', includeValue: true }, + ]); expect(awsInfo.gatheredData).to.deep.equal(expectedGatheredDataObj); }); }); + it('limits concurrent API Gateway getApiKey requests to 2', async () => { + awsInfo.serverless.service.provider.apiGateway = { apiKeys: ['foo'] }; + awsInfo.gatheredData = { info: {} }; + cloudFormationSendStub.resolves({ + StackResources: Array.from({ length: 10 }, (_, index) => ({ + PhysicalResourceId: `api-key-${index}`, + ResourceType: 'AWS::ApiGateway::ApiKey', + })), + }); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + apiGatewaySendStub.callsFake(async (command) => { + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(2); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { + name: command.input.apiKey, + value: `value-${command.input.apiKey}`, + }; + }); + + const promise = awsInfo.getApiKeyValues(); + + for (let index = 0; index < 10 && !pendingResolvers.length; index++) { + await Promise.resolve(); + } + expect(cloudFormationSendStub).to.have.been.calledOnce; + expect(observedMaxActiveRequests).to.equal(2); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(2); + }); + it('should resolve if AWS does not return API key values', async () => { // set the API Keys for the service awsInfo.serverless.service.provider.apiGateway = { apiKeys: ['foo', 'bar'] }; @@ -101,7 +179,7 @@ describe('#getApiKeyValues()', () => { items: [], }; - requestStub.resolves(apiKeyItems); + cloudFormationSendStub.resolves(apiKeyItems); const expectedGatheredDataObj = { info: { @@ -110,7 +188,8 @@ describe('#getApiKeyValues()', () => { }; return awsInfo.getApiKeyValues().then(() => { - expect(requestStub.calledOnce).to.equal(true); + expect(cloudFormationSendStub).to.have.been.calledOnce; + expect(apiGatewaySendStub).to.not.have.been.called; expect(awsInfo.gatheredData).to.deep.equal(expectedGatheredDataObj); }); }); @@ -129,7 +208,8 @@ describe('#getApiKeyValues()', () => { }; return awsInfo.getApiKeyValues().then(() => { - expect(requestStub.calledOnce).to.equal(false); + expect(cloudFormationSendStub).to.not.have.been.called; + expect(apiGatewaySendStub).to.not.have.been.called; expect(awsInfo.gatheredData).to.deep.equal(expectedGatheredDataObj); }); }); diff --git a/test/unit/lib/plugins/aws/info/get-resource-count.test.js b/test/unit/lib/plugins/aws/info/get-resource-count.test.js index 009f0adf8..364437592 100644 --- a/test/unit/lib/plugins/aws/info/get-resource-count.test.js +++ b/test/unit/lib/plugins/aws/info/get-resource-count.test.js @@ -5,6 +5,10 @@ const sinon = require('sinon'); const AwsInfo = require('../../../../../../lib/plugins/aws/info/index'); const AwsProvider = require('../../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../../lib/serverless'); +const { + CloudFormationClient, + ListStackResourcesCommand, +} = require('@aws-sdk/client-cloudformation'); describe('#getResourceCount()', () => { let serverless; @@ -25,11 +29,11 @@ describe('#getResourceCount()', () => { }; awsInfo = new AwsInfo(serverless, options); - listStackResourcesStub = sinon.stub(awsInfo.provider, 'request'); + listStackResourcesStub = sinon.stub(CloudFormationClient.prototype, 'send'); }); afterEach(() => { - awsInfo.provider.request.restore(); + CloudFormationClient.prototype.send.restore(); }); it('attach resourceCount to this.gatheredData after listStackResources call', async () => { @@ -126,14 +130,32 @@ describe('#getResourceCount()', () => { return expect(awsInfo.getResourceCount()).to.be.fulfilled.then(() => { expect(listStackResourcesStub.calledOnce).to.equal(true); - expect( - listStackResourcesStub.calledWithExactly('CloudFormation', 'listStackResources', { - StackName: awsInfo.provider.naming.getStackName(), - NextToken: undefined, - }) - ).to.equal(true); + expect(listStackResourcesStub.firstCall.args[0]).to.be.instanceOf(ListStackResourcesCommand); + expect(listStackResourcesStub.firstCall.args[0].input).to.deep.equal({ + StackName: awsInfo.provider.naming.getStackName(), + NextToken: undefined, + }); expect(awsInfo.gatheredData.info.resourceCount).to.equal(10); }); }); + + it('accumulates resourceCount across paginated listStackResources calls', async () => { + listStackResourcesStub + .onFirstCall() + .resolves({ StackResourceSummaries: [{}, {}], NextToken: 'next' }) + .onSecondCall() + .resolves({ StackResourceSummaries: [{}] }); + + awsInfo.gatheredData = { info: {}, outputs: [] }; + + await awsInfo.getResourceCount(); + + expect(listStackResourcesStub).to.have.been.calledTwice; + expect(listStackResourcesStub.secondCall.args[0].input).to.deep.equal({ + StackName: awsInfo.provider.naming.getStackName(), + NextToken: 'next', + }); + expect(awsInfo.gatheredData.info.resourceCount).to.equal(3); + }); }); diff --git a/test/unit/lib/plugins/aws/info/get-stack-info.test.js b/test/unit/lib/plugins/aws/info/get-stack-info.test.js index e5b7f10b5..8f799a02d 100644 --- a/test/unit/lib/plugins/aws/info/get-stack-info.test.js +++ b/test/unit/lib/plugins/aws/info/get-stack-info.test.js @@ -5,11 +5,14 @@ const sinon = require('sinon'); const AwsInfo = require('../../../../../../lib/plugins/aws/info/index'); const AwsProvider = require('../../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../../lib/serverless'); +const { CloudFormationClient, DescribeStacksCommand } = require('@aws-sdk/client-cloudformation'); +const { ApiGatewayV2Client, GetApiCommand } = require('@aws-sdk/client-apigatewayv2'); describe('#getStackInfo()', () => { let serverless; let awsInfo; let describeStacksStub; + let getApiStub; beforeEach(() => { const options = { @@ -26,11 +29,13 @@ describe('#getStackInfo()', () => { serverless.service.layers = { test: {} }; awsInfo = new AwsInfo(serverless, options); - describeStacksStub = sinon.stub(awsInfo.provider, 'request'); + describeStacksStub = sinon.stub(CloudFormationClient.prototype, 'send'); + getApiStub = sinon.stub(ApiGatewayV2Client.prototype, 'send'); }); afterEach(() => { - awsInfo.provider.request.restore(); + CloudFormationClient.prototype.send.restore(); + ApiGatewayV2Client.prototype.send.restore(); }); it('attach info from describeStack call to this.gatheredData if result is available', async () => { @@ -152,11 +157,10 @@ describe('#getStackInfo()', () => { return awsInfo.getStackInfo().then(() => { expect(describeStacksStub.calledOnce).to.equal(true); - expect( - describeStacksStub.calledWithExactly('CloudFormation', 'describeStacks', { - StackName: awsInfo.provider.naming.getStackName(), - }) - ).to.equal(true); + expect(describeStacksStub.firstCall.args[0]).to.be.instanceOf(DescribeStacksCommand); + expect(describeStacksStub.firstCall.args[0].input).to.deep.equal({ + StackName: awsInfo.provider.naming.getStackName(), + }); expect(awsInfo.gatheredData).to.deep.equal(expectedGatheredDataObj); }); @@ -182,11 +186,10 @@ describe('#getStackInfo()', () => { return awsInfo.getStackInfo().then(() => { expect(describeStacksStub.calledOnce).to.equal(true); - expect( - describeStacksStub.calledWithExactly('CloudFormation', 'describeStacks', { - StackName: awsInfo.provider.naming.getStackName(), - }) - ).to.equal(true); + expect(describeStacksStub.firstCall.args[0]).to.be.instanceOf(DescribeStacksCommand); + expect(describeStacksStub.firstCall.args[0].input).to.deep.equal({ + StackName: awsInfo.provider.naming.getStackName(), + }); expect(awsInfo.gatheredData).to.deep.equal(expectedGatheredDataObj); }); @@ -197,19 +200,10 @@ describe('#getStackInfo()', () => { id: 'http-api-id', }; - describeStacksStub - .withArgs('CloudFormation', 'describeStacks', { - StackName: awsInfo.provider.naming.getStackName(), - }) - .resolves(null); - - describeStacksStub - .withArgs('ApiGatewayV2', 'getApi', { - ApiId: 'http-api-id', - }) - .resolves({ - ApiEndpoint: 'my-endpoint', - }); + describeStacksStub.resolves(null); + getApiStub.resolves({ + ApiEndpoint: 'my-endpoint', + }); const expectedGatheredDataObj = { info: { @@ -225,17 +219,13 @@ describe('#getStackInfo()', () => { }; return awsInfo.getStackInfo().then(() => { - expect(describeStacksStub.calledTwice).to.equal(true); - expect( - describeStacksStub.calledWithExactly('CloudFormation', 'describeStacks', { - StackName: awsInfo.provider.naming.getStackName(), - }) - ).to.equal(true); - expect( - describeStacksStub.calledWithExactly('ApiGatewayV2', 'getApi', { - ApiId: 'http-api-id', - }) - ).to.equal(true); + expect(describeStacksStub).to.have.been.calledOnce; + expect(describeStacksStub.firstCall.args[0].input).to.deep.equal({ + StackName: awsInfo.provider.naming.getStackName(), + }); + expect(getApiStub).to.have.been.calledOnce; + expect(getApiStub.firstCall.args[0]).to.be.instanceOf(GetApiCommand); + expect(getApiStub.firstCall.args[0].input).to.deep.equal({ ApiId: 'http-api-id' }); expect(awsInfo.gatheredData).to.deep.equal(expectedGatheredDataObj); }); diff --git a/test/unit/lib/plugins/aws/invoke-local/index.test.js b/test/unit/lib/plugins/aws/invoke-local/index.test.js index 54df1f826..f3bcc3c55 100644 --- a/test/unit/lib/plugins/aws/invoke-local/index.test.js +++ b/test/unit/lib/plugins/aws/invoke-local/index.test.js @@ -13,6 +13,7 @@ const log = require('log').get('serverless:test'); const proxyquire = require('proxyquire'); const overrideEnv = require('process-utils/override-env'); const AwsProvider = require('../../../../../../lib/plugins/aws/provider'); +const { CloudFormationClient } = require('@aws-sdk/client-cloudformation'); const Serverless = require('../../../../../../lib/serverless'); const CLI = require('../../../../../../lib/classes/cli'); const { getTmpDirPath } = require('../../../../../utils/fs'); @@ -369,7 +370,7 @@ describe('AwsInvokeLocal', () => { }); it('resolves Fn::ImportValue env vars', async () => { - requestStub.resolves({ + const listExportsStub = sinon.stub(CloudFormationClient.prototype, 'send').resolves({ Exports: [{ Name: 'some-export', Value: 'imported-value' }], }); @@ -382,6 +383,8 @@ describe('AwsInvokeLocal', () => { expect(result).to.deep.equal({ IMPORTED: 'imported-value', }); + expect(listExportsStub).to.have.been.calledOnce; + CloudFormationClient.prototype.send.restore(); }); it('resolves Ref env vars', async () => { diff --git a/test/unit/lib/plugins/aws/lib/check-if-bucket-exists.test.js b/test/unit/lib/plugins/aws/lib/check-if-bucket-exists.test.js new file mode 100644 index 000000000..5cb31c710 --- /dev/null +++ b/test/unit/lib/plugins/aws/lib/check-if-bucket-exists.test.js @@ -0,0 +1,74 @@ +'use strict'; + +const { expect } = require('chai'); +const sinon = require('sinon'); +const { S3Client, HeadBucketCommand } = require('@aws-sdk/client-s3'); +const checkIfBucketExists = require('../../../../../../lib/plugins/aws/lib/check-if-bucket-exists'); + +describe('test/unit/lib/plugins/aws/lib/check-if-bucket-exists.test.js', () => { + let context; + let headBucketStub; + + beforeEach(() => { + context = { + provider: { + getAwsSdkV3Config: sinon + .stub() + .resolves({ region: 'us-east-1', credentials: sinon.stub() }), + }, + ...checkIfBucketExists, + }; + headBucketStub = sinon.stub(S3Client.prototype, 'send'); + }); + + afterEach(() => { + S3Client.prototype.send.restore(); + }); + + it('returns true when HeadBucket succeeds', async () => { + headBucketStub.resolves({}); + + await expect(context.checkIfBucketExists('bucket')).to.eventually.equal(true); + + expect(headBucketStub).to.have.been.calledOnce; + expect(headBucketStub.firstCall.args[0]).to.be.instanceOf(HeadBucketCommand); + expect(headBucketStub.firstCall.args[0].input).to.deep.equal({ Bucket: 'bucket' }); + }); + + for (const error of [ + { code: 'AWS_S3_HEAD_BUCKET_NOT_FOUND' }, + { name: 'NotFound' }, + { name: 'NoSuchBucket' }, + { $metadata: { httpStatusCode: 404 } }, + ]) { + it(`returns false for missing bucket shape ${JSON.stringify(error)}`, async () => { + headBucketStub.rejects(error); + + await expect(context.checkIfBucketExists('bucket')).to.eventually.equal(false); + }); + } + + for (const error of [ + { code: 'AWS_S3_HEAD_BUCKET_FORBIDDEN' }, + { name: 'Forbidden' }, + { name: 'AccessDenied' }, + { $metadata: { httpStatusCode: 403 } }, + ]) { + it(`throws stable forbidden error for shape ${JSON.stringify(error)}`, async () => { + headBucketStub.rejects(error); + + try { + await context.checkIfBucketExists('bucket'); + throw new Error('Expected checkIfBucketExists to reject'); + } catch (caughtError) { + expect(caughtError.code).to.equal('AWS_S3_HEAD_BUCKET_FORBIDDEN'); + } + }); + } + + it('rethrows unexpected errors', async () => { + headBucketStub.rejects(new Error('boom')); + + await expect(context.checkIfBucketExists('bucket')).to.be.rejectedWith('boom'); + }); +}); diff --git a/test/unit/lib/plugins/aws/lib/check-if-ecr-repository-exists.test.js b/test/unit/lib/plugins/aws/lib/check-if-ecr-repository-exists.test.js new file mode 100644 index 000000000..1584f9b6d --- /dev/null +++ b/test/unit/lib/plugins/aws/lib/check-if-ecr-repository-exists.test.js @@ -0,0 +1,114 @@ +'use strict'; + +const { expect } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); +const { ECRClient, DescribeRepositoriesCommand } = require('@aws-sdk/client-ecr'); + +describe('test/unit/lib/plugins/aws/lib/check-if-ecr-repository-exists.test.js', () => { + let context; + let describeRepositoriesStub; + let warningStub; + + beforeEach(() => { + warningStub = sinon.stub(); + const checkIfEcrRepositoryExists = proxyquire + .noCallThru() + .load('../../../../../../lib/plugins/aws/lib/check-if-ecr-repository-exists', { + '../../../utils/serverless-utils/log': { log: { warning: warningStub } }, + }); + context = { + provider: { + getAccountId: sinon.stub().resolves('123456789012'), + getAwsSdkV3Config: sinon + .stub() + .resolves({ region: 'us-east-1', credentials: sinon.stub() }), + naming: { + getEcrRepositoryName: sinon.stub().returns('repository'), + }, + }, + serverless: { + service: { + provider: {}, + }, + }, + ...checkIfEcrRepositoryExists, + }; + describeRepositoriesStub = sinon.stub(ECRClient.prototype, 'send'); + }); + + afterEach(() => { + ECRClient.prototype.send.restore(); + }); + + it('returns true when repository exists', async () => { + describeRepositoriesStub.resolves({ repositories: [{ repositoryName: 'repository' }] }); + + await expect(context.checkIfEcrRepositoryExists()).to.eventually.equal(true); + + expect(context.provider.getAccountId).to.have.been.calledOnce; + expect(describeRepositoriesStub.firstCall.args[0]).to.be.instanceOf( + DescribeRepositoriesCommand + ); + expect(describeRepositoriesStub.firstCall.args[0].input).to.deep.equal({ + repositoryNames: ['repository'], + registryId: '123456789012', + }); + }); + + for (const error of [ + { providerError: { code: 'RepositoryNotFoundException' } }, + { name: 'RepositoryNotFoundException' }, + ]) { + it(`returns false for missing repository shape ${JSON.stringify(error)}`, async () => { + describeRepositoriesStub.rejects(error); + + await expect(context.checkIfEcrRepositoryExists()).to.eventually.equal(false); + }); + } + + for (const error of [ + { providerError: { code: 'AccessDeniedException' } }, + { name: 'AccessDeniedException' }, + ]) { + it(`returns false for access denied shape ${JSON.stringify(error)}`, async () => { + describeRepositoriesStub.rejects(error); + + await expect(context.checkIfEcrRepositoryExists()).to.eventually.equal(false); + expect(warningStub).to.not.have.been.called; + }); + } + + for (const error of [ + { name: 'InvalidSignatureException', $metadata: { httpStatusCode: 403 } }, + { name: 'UnrecognizedClientException', $metadata: { httpStatusCode: 403 } }, + { $metadata: { httpStatusCode: 403 } }, + ]) { + it(`rethrows non-access-denied 403 ECR error ${JSON.stringify(error)}`, async () => { + describeRepositoriesStub.rejects(error); + + try { + await context.checkIfEcrRepositoryExists(); + throw new Error('Expected checkIfEcrRepositoryExists to reject'); + } catch (caughtError) { + expect(caughtError).to.equal(error); + } + expect(warningStub).to.not.have.been.called; + }); + } + + it('warns on access denied when provider ECR images are configured', async () => { + context.serverless.service.provider.ecr = { images: { image: { path: './' } } }; + describeRepositoriesStub.rejects({ name: 'AccessDeniedException' }); + + await expect(context.checkIfEcrRepositoryExists()).to.eventually.equal(false); + + expect(warningStub).to.have.been.calledOnce; + }); + + it('rethrows unexpected errors', async () => { + describeRepositoriesStub.rejects(new Error('boom')); + + await expect(context.checkIfEcrRepositoryExists()).to.be.rejectedWith('boom'); + }); +}); diff --git a/test/unit/lib/plugins/aws/logs.test.js b/test/unit/lib/plugins/aws/logs.test.js index 00a9c2cdf..552bff455 100644 --- a/test/unit/lib/plugins/aws/logs.test.js +++ b/test/unit/lib/plugins/aws/logs.test.js @@ -5,6 +5,11 @@ const proxyquire = require('proxyquire'); const AwsProvider = require('../../../../../lib/plugins/aws/provider'); const AwsLogs = require('../../../../../lib/plugins/aws/logs'); const Serverless = require('../../../../../lib/serverless'); +const { + CloudWatchLogsClient, + DescribeLogStreamsCommand, + FilterLogEventsCommand, +} = require('@aws-sdk/client-cloudwatch-logs'); // Configure chai const expect = require('chai').expect; @@ -123,35 +128,36 @@ describe('AwsLogs', () => { }, ], }; - const getLogStreamsStub = sinon.stub(awsLogs.provider, 'request').resolves(replyMock); + const getLogStreamsStub = sinon + .stub(CloudWatchLogsClient.prototype, 'send') + .resolves(replyMock); const logStreamNames = await awsLogs.getLogStreams(); expect(getLogStreamsStub.calledOnce).to.be.equal(true); - expect( - getLogStreamsStub.calledWithExactly('CloudWatchLogs', 'describeLogStreams', { - logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), - descending: true, - limit: 50, - orderBy: 'LastEventTime', - }) - ).to.be.equal(true); + expect(getLogStreamsStub.firstCall.args[0]).to.be.instanceOf(DescribeLogStreamsCommand); + expect(getLogStreamsStub.firstCall.args[0].input).to.deep.equal({ + logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), + descending: true, + limit: 50, + orderBy: 'LastEventTime', + }); expect(logStreamNames[0]).to.be.equal('2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba'); expect(logStreamNames[1]).to.be.equal('2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba'); - awsLogs.provider.request.restore(); + CloudWatchLogsClient.prototype.send.restore(); }); it('should throw error if no log streams found', async () => { - sinon.stub(awsLogs.provider, 'request').resolves(); + sinon.stub(CloudWatchLogsClient.prototype, 'send').resolves(); await expect(awsLogs.getLogStreams()).to.eventually.be.rejected.and.have.property( 'name', 'ServerlessError' ); - awsLogs.provider.request.restore(); + CloudWatchLogsClient.prototype.send.restore(); }); }); @@ -188,7 +194,9 @@ describe('AwsLogs', () => { '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba', '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba', ]; - const filterLogEventsStub = sinon.stub(awsLogs.provider, 'request').resolves(replyMock); + const filterLogEventsStub = sinon + .stub(CloudWatchLogsClient.prototype, 'send') + .resolves(replyMock); awsLogs.serverless.service.service = 'new-service'; awsLogs.options = { stage: 'dev', @@ -202,16 +210,15 @@ describe('AwsLogs', () => { await awsLogs.showLogs(logStreamNamesMock); expect(filterLogEventsStub.calledOnce).to.be.equal(true); - expect( - filterLogEventsStub.calledWithExactly('CloudWatchLogs', 'filterLogEvents', { - logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), - interleaved: true, - logStreamNames: logStreamNamesMock, - filterPattern: 'error', - startTime: fakeTime - 3 * 60 * 60 * 1000, // -3h - }) - ).to.be.equal(true); - awsLogs.provider.request.restore(); + expect(filterLogEventsStub.firstCall.args[0]).to.be.instanceOf(FilterLogEventsCommand); + expect(filterLogEventsStub.firstCall.args[0].input).to.deep.equal({ + logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), + interleaved: true, + logStreamNames: logStreamNamesMock, + filterPattern: 'error', + startTime: fakeTime - 3 * 60 * 60 * 1000, // -3h + }); + CloudWatchLogsClient.prototype.send.restore(); }); it('should call filterLogEvents API with standard start time', async () => { @@ -233,7 +240,9 @@ describe('AwsLogs', () => { '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba', '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba', ]; - const filterLogEventsStub = sinon.stub(awsLogs.provider, 'request').resolves(replyMock); + const filterLogEventsStub = sinon + .stub(CloudWatchLogsClient.prototype, 'send') + .resolves(replyMock); awsLogs.serverless.service.service = 'new-service'; awsLogs.options = { stage: 'dev', @@ -247,17 +256,16 @@ describe('AwsLogs', () => { await awsLogs.showLogs(logStreamNamesMock); expect(filterLogEventsStub.calledOnce).to.be.equal(true); - expect( - filterLogEventsStub.calledWithExactly('CloudWatchLogs', 'filterLogEvents', { - logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), - interleaved: true, - logStreamNames: logStreamNamesMock, - startTime: 1287532800000, // '2010-10-20' - filterPattern: 'error', - }) - ).to.be.equal(true); - - awsLogs.provider.request.restore(); + expect(filterLogEventsStub.firstCall.args[0]).to.be.instanceOf(FilterLogEventsCommand); + expect(filterLogEventsStub.firstCall.args[0].input).to.deep.equal({ + logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), + interleaved: true, + logStreamNames: logStreamNamesMock, + startTime: 1287532800000, // '2010-10-20' + filterPattern: 'error', + }); + + CloudWatchLogsClient.prototype.send.restore(); }); it('should call filterLogEvents API with latest 10 minutes if startTime not given', async () => { @@ -279,7 +287,9 @@ describe('AwsLogs', () => { '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba', '2016/07/28/[$LATEST]83f5206ab2a8488290349b9c1fbfe2ba', ]; - const filterLogEventsStub = sinon.stub(awsLogs.provider, 'request').resolves(replyMock); + const filterLogEventsStub = sinon + .stub(CloudWatchLogsClient.prototype, 'send') + .resolves(replyMock); awsLogs.serverless.service.service = 'new-service'; awsLogs.options = { stage: 'dev', @@ -291,16 +301,15 @@ describe('AwsLogs', () => { await awsLogs.showLogs(logStreamNamesMock); expect(filterLogEventsStub.calledOnce).to.be.equal(true); - expect( - filterLogEventsStub.calledWithExactly('CloudWatchLogs', 'filterLogEvents', { - logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), - interleaved: true, - logStreamNames: logStreamNamesMock, - startTime: fakeTime - 10 * 60 * 1000, // fakeTime - 10 minutes - }) - ).to.be.equal(true); - - awsLogs.provider.request.restore(); + expect(filterLogEventsStub.firstCall.args[0]).to.be.instanceOf(FilterLogEventsCommand); + expect(filterLogEventsStub.firstCall.args[0].input).to.deep.equal({ + logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), + interleaved: true, + logStreamNames: logStreamNamesMock, + startTime: fakeTime - 10 * 60 * 1000, // fakeTime - 10 minutes + }); + + CloudWatchLogsClient.prototype.send.restore(); }); it('should call filterLogEvents API which starts 10 seconds in the past if tail given', async () => { @@ -342,7 +351,9 @@ describe('AwsLogs', () => { serverless.processedInput = { commands: ['logs'] }; const mockedAwsLogs = new MockedAwsLogs(serverless, options); - const filterLogEventsStub = sinon.stub(mockedAwsLogs.provider, 'request').resolves(replyMock); + const filterLogEventsStub = sinon + .stub(CloudWatchLogsClient.prototype, 'send') + .resolves(replyMock); mockedAwsLogs.serverless.service.service = 'new-service'; mockedAwsLogs.options = { stage: 'dev', @@ -359,14 +370,14 @@ describe('AwsLogs', () => { } expect(filterLogEventsStub.calledOnce).to.be.equal(true); - expect( - filterLogEventsStub.calledWithExactly('CloudWatchLogs', 'filterLogEvents', { - logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), - interleaved: true, - logStreamNames: logStreamNamesMock, - startTime: fakeTime - 10 * 1000, // fakeTime - 10 minutes - }) - ).to.be.equal(true); + expect(filterLogEventsStub.firstCall.args[0]).to.be.instanceOf(FilterLogEventsCommand); + expect(filterLogEventsStub.firstCall.args[0].input).to.deep.equal({ + logGroupName: awsLogs.provider.naming.getLogGroupName('new-service-dev-first'), + interleaved: true, + logStreamNames: logStreamNamesMock, + startTime: fakeTime - 10 * 1000, // fakeTime - 10 seconds + }); + CloudWatchLogsClient.prototype.send.restore(); }); }); }); diff --git a/test/unit/lib/plugins/aws/metrics.test.js b/test/unit/lib/plugins/aws/metrics.test.js index bf6928516..777ca03b2 100644 --- a/test/unit/lib/plugins/aws/metrics.test.js +++ b/test/unit/lib/plugins/aws/metrics.test.js @@ -8,11 +8,27 @@ const AwsMetrics = require('../../../../../lib/plugins/aws/metrics'); const Serverless = require('../../../../../lib/serverless'); const CLI = require('../../../../../lib/classes/cli'); const dayjs = require('dayjs'); +const { CloudWatchClient, GetMetricStatisticsCommand } = require('@aws-sdk/client-cloudwatch'); const LocalizedFormat = require('dayjs/plugin/localizedFormat'); dayjs.extend(LocalizedFormat); +async function releasePendingRequestsUntilSettled(pendingResolvers, promise) { + let isSettled = false; + promise + .finally(() => { + isSettled = true; + }) + .catch(() => {}); + while (!isSettled) { + await Promise.resolve(); + for (const resolve of pendingResolvers.splice(0)) resolve(); + await Promise.resolve(); + } + return promise; +} + describe('AwsMetrics', () => { let awsMetrics; let serverless; @@ -179,11 +195,11 @@ describe('AwsMetrics', () => { }; awsMetrics.options.startTime = new Date('1970-01-01'); awsMetrics.options.endTime = new Date('1970-01-02'); - requestStub = sinon.stub(awsMetrics.provider, 'request'); + requestStub = sinon.stub(CloudWatchClient.prototype, 'send'); }); afterEach(() => { - awsMetrics.provider.request.restore(); + CloudWatchClient.prototype.send.restore(); }); it('should gather service wide function metrics if no function option is specified', async () => { @@ -348,6 +364,33 @@ describe('AwsMetrics', () => { expect(result).to.deep.equal(expectedResult); }); + it('limits total CloudWatch metric requests to 6 across all functions', async () => { + awsMetrics.serverless.service.functions = Object.fromEntries( + Array.from({ length: 3 }, (_, index) => [`function${index}`, { name: `func${index}` }]) + ); + let activeRequests = 0; + let observedMaxActiveRequests = 0; + const pendingResolvers = []; + requestStub.callsFake(async (command) => { + activeRequests += 1; + observedMaxActiveRequests = Math.max(observedMaxActiveRequests, activeRequests); + expect(activeRequests).to.be.at.most(6); + await new Promise((resolve) => pendingResolvers.push(resolve)); + activeRequests -= 1; + return { + Label: command.input.MetricName, + Datapoints: [], + }; + }); + + const promise = awsMetrics.getMetrics(); + + await Promise.resolve(); + expect(observedMaxActiveRequests).to.equal(6); + await releasePendingRequestsUntilSettled(pendingResolvers, promise); + expect(observedMaxActiveRequests).to.equal(6); + }); + it('should gather metrics with 1 hour period for time span < 24 hours', async () => { awsMetrics.options.startTime = new Date('1970-01-01T09:00'); awsMetrics.options.endTime = new Date('1970-01-01T16:00'); @@ -355,11 +398,13 @@ describe('AwsMetrics', () => { await awsMetrics.getMetrics(); expect( - requestStub.calledWith( - sinon.match.string, - sinon.match.string, - sinon.match.has('Period', 3600) - ) + requestStub + .getCalls() + .some( + (call) => + call.args[0] instanceof GetMetricStatisticsCommand && + call.args[0].input.Period === 3600 + ) ).to.equal(true); }); @@ -370,11 +415,13 @@ describe('AwsMetrics', () => { await awsMetrics.getMetrics(); expect( - requestStub.calledWith( - sinon.match.string, - sinon.match.string, - sinon.match.has('Period', 24 * 3600) - ) + requestStub + .getCalls() + .some( + (call) => + call.args[0] instanceof GetMetricStatisticsCommand && + call.args[0].input.Period === 24 * 3600 + ) ).to.equal(true); }); @@ -385,11 +432,13 @@ describe('AwsMetrics', () => { await awsMetrics.getMetrics(); expect( - requestStub.calledWith( - sinon.match.string, - sinon.match.string, - sinon.match.has('Period', 3600) - ) + requestStub + .getCalls() + .some( + (call) => + call.args[0] instanceof GetMetricStatisticsCommand && + call.args[0].input.Period === 3600 + ) ).to.equal(true); }); }); diff --git a/test/unit/lib/plugins/aws/provider.test.js b/test/unit/lib/plugins/aws/provider.test.js index bc8258916..393f78dd6 100644 --- a/test/unit/lib/plugins/aws/provider.test.js +++ b/test/unit/lib/plugins/aws/provider.test.js @@ -11,6 +11,11 @@ const overrideEnv = require('process-utils/override-env'); const AwsProvider = require('../../../../../lib/plugins/aws/provider'); const Serverless = require('../../../../../lib/serverless'); const runServerless = require('../../../../utils/run-serverless'); +const { + CloudFormationClient, + DescribeStackResourceCommand, +} = require('@aws-sdk/client-cloudformation'); +const { STSClient, GetCallerIdentityCommand } = require('@aws-sdk/client-sts'); const expect = chai.expect; const spawnModulePath = path.resolve(__dirname, '../../../../../lib/utils/spawn.js'); @@ -421,7 +426,10 @@ describe('AwsProvider', () => { it('passes profile and custom options to the config helpers', async () => { const buildClientConfigStub = sinon.stub().returns({ config: true }); - const getAwsSdkV3CredentialsProviderStub = sinon.stub().returns('credentials'); + const credentialsProvider = sinon + .stub() + .resolves({ accessKeyId: 'key', secretAccessKey: 'secret' }); + const getAwsSdkV3CredentialsProviderStub = sinon.stub().returns(credentialsProvider); const getAwsSdkV3CredentialsProviderCacheKeyStub = sinon.stub().returns('cache-key'); const AwsProviderProxyquired = proxyquire .noCallThru() @@ -445,12 +453,68 @@ describe('AwsProvider', () => { provider, profile: 'custom-profile', }); - expect(buildClientConfigStub).to.have.been.calledOnceWithExactly({ + expect(buildClientConfigStub).to.have.been.calledOnce; + const buildConfigInput = buildClientConfigStub.firstCall.args[0]; + expect(buildConfigInput).to.include({ maxAttempts: 2, retryMode: 'adaptive', region: 'us-east-1', - credentials: 'credentials', }); + expect(buildConfigInput.credentials).to.be.a('function'); + await expect(buildConfigInput.credentials({ caller: 'test' })).to.eventually.deep.equal({ + accessKeyId: 'key', + secretAccessKey: 'secret', + }); + expect(credentialsProvider).to.have.been.calledOnceWithExactly({ caller: 'test' }); + }); + + it('normalizes generic SDK v3 missing credentials errors', async () => { + const missingCredentialsError = new Error('Could not load credentials from any providers'); + const credentialsProvider = sinon.stub().rejects(missingCredentialsError); + const AwsProviderProxyquired = proxyquire + .noCallThru() + .load('../../../../../lib/plugins/aws/provider.js', { + '../../aws/credentials': { + getAwsSdkV3CredentialsProviderCacheKey: sinon.stub().returns('cache-key'), + getAwsSdkV3CredentialsProvider: sinon.stub().returns(credentialsProvider), + }, + }); + const provider = new AwsProviderProxyquired(serverless, options); + + const config = await provider.getAwsSdkV3Config(); + + try { + await config.credentials(); + throw new Error('Expected credentials provider to reject'); + } catch (error) { + expect(error.code).to.equal('AWS_CREDENTIALS_NOT_FOUND'); + } + }); + + it('does not normalize specific SDK v3 credential provider errors', async () => { + const ssoError = Object.assign(new Error('The SSO session has expired'), { + name: 'CredentialsProviderError', + }); + const credentialsProvider = sinon.stub().rejects(ssoError); + const AwsProviderProxyquired = proxyquire + .noCallThru() + .load('../../../../../lib/plugins/aws/provider.js', { + '../../aws/credentials': { + getAwsSdkV3CredentialsProviderCacheKey: sinon.stub().returns('cache-key'), + getAwsSdkV3CredentialsProvider: sinon.stub().returns(credentialsProvider), + }, + }); + const provider = new AwsProviderProxyquired(serverless, options); + + const config = await provider.getAwsSdkV3Config(); + + try { + await config.credentials(); + throw new Error('Expected credentials provider to reject'); + } catch (error) { + expect(error).to.equal(ssoError); + expect(error.code).to.not.equal('AWS_CREDENTIALS_NOT_FOUND'); + } }); it('reuses SDK v3 credential providers for the same credential source', async () => { @@ -514,38 +578,43 @@ describe('AwsProvider', () => { describe('#getServerlessDeploymentBucketName()', () => { it('should return the name of the serverless deployment bucket', async () => { - const describeStackResourcesStub = sinon.stub(awsProvider, 'request').resolves({ - StackResourceDetail: { - PhysicalResourceId: 'serverlessDeploymentBucketName', - }, - }); + const describeStackResourcesStub = sinon + .stub(CloudFormationClient.prototype, 'send') + .resolves({ + StackResourceDetail: { + PhysicalResourceId: 'serverlessDeploymentBucketName', + }, + }); return awsProvider.getServerlessDeploymentBucketName().then((bucketName) => { expect(bucketName).to.equal('serverlessDeploymentBucketName'); expect(describeStackResourcesStub.calledOnce).to.be.equal(true); - expect( - describeStackResourcesStub.calledWithExactly('CloudFormation', 'describeStackResource', { - StackName: awsProvider.naming.getStackName(), - LogicalResourceId: awsProvider.naming.getDeploymentBucketLogicalId(), - }) - ).to.be.equal(true); - awsProvider.request.restore(); + expect(describeStackResourcesStub.firstCall.args[0]).to.be.instanceOf( + DescribeStackResourceCommand + ); + expect(describeStackResourcesStub.firstCall.args[0].input).to.deep.equal({ + StackName: awsProvider.naming.getStackName(), + LogicalResourceId: awsProvider.naming.getDeploymentBucketLogicalId(), + }); + CloudFormationClient.prototype.send.restore(); }); }); it('should return the name of the custom deployment bucket', async () => { awsProvider.serverless.service.provider.deploymentBucket = 'custom-bucket'; - const describeStackResourcesStub = sinon.stub(awsProvider, 'request').resolves({ - StackResourceDetail: { - PhysicalResourceId: 'serverlessDeploymentBucketName', - }, - }); + const describeStackResourcesStub = sinon + .stub(CloudFormationClient.prototype, 'send') + .resolves({ + StackResourceDetail: { + PhysicalResourceId: 'serverlessDeploymentBucketName', + }, + }); return awsProvider.getServerlessDeploymentBucketName().then((bucketName) => { expect(describeStackResourcesStub.called).to.be.equal(false); expect(bucketName).to.equal('custom-bucket'); - awsProvider.request.restore(); + CloudFormationClient.prototype.send.restore(); }); }); }); @@ -555,7 +624,7 @@ describe('AwsProvider', () => { const accountId = '12345678'; const partition = 'aws'; - const stsGetCallerIdentityStub = sinon.stub(awsProvider, 'request').resolves({ + const stsGetCallerIdentityStub = sinon.stub(STSClient.prototype, 'send').resolves({ ResponseMetadata: { RequestId: '12345678-1234-1234-1234-123456789012' }, UserId: 'ABCDEFGHIJKLMNOPQRSTU:VWXYZ', Account: accountId, @@ -564,9 +633,13 @@ describe('AwsProvider', () => { return awsProvider.getAccountInfo().then((result) => { expect(stsGetCallerIdentityStub.calledOnce).to.equal(true); + expect(stsGetCallerIdentityStub.firstCall.args[0]).to.be.instanceOf( + GetCallerIdentityCommand + ); + expect(stsGetCallerIdentityStub.firstCall.args[0].input).to.deep.equal({}); expect(result.accountId).to.equal(accountId); expect(result.partition).to.equal(partition); - awsProvider.request.restore(); + STSClient.prototype.send.restore(); }); }); }); @@ -575,7 +648,7 @@ describe('AwsProvider', () => { it('should return the AWS account id', async () => { const accountId = '12345678'; - const stsGetCallerIdentityStub = sinon.stub(awsProvider, 'request').resolves({ + const stsGetCallerIdentityStub = sinon.stub(STSClient.prototype, 'send').resolves({ ResponseMetadata: { RequestId: '12345678-1234-1234-1234-123456789012' }, UserId: 'ABCDEFGHIJKLMNOPQRSTU:VWXYZ', Account: accountId, @@ -585,7 +658,7 @@ describe('AwsProvider', () => { return awsProvider.getAccountId().then((result) => { expect(stsGetCallerIdentityStub.calledOnce).to.equal(true); expect(result).to.equal(accountId); - awsProvider.request.restore(); + STSClient.prototype.send.restore(); }); }); }); diff --git a/test/unit/lib/plugins/aws/remove/index.test.js b/test/unit/lib/plugins/aws/remove/index.test.js index 5423f140d..fc2eb7b11 100644 --- a/test/unit/lib/plugins/aws/remove/index.test.js +++ b/test/unit/lib/plugins/aws/remove/index.test.js @@ -306,6 +306,57 @@ describe('test/unit/lib/plugins/aws/remove/index.test.js', () => { expect(describeStackEventsStub.calledAfter(deleteStackStub)).to.be.true; }); + it('skips S3 object removal if SDK v3 reports deployment bucket resource missing', async () => { + const headBucketStub = sinon.stub(); + const { awsNaming } = await runServerless({ + fixture: 'function', + command: 'remove', + awsRequestStubMap: { + ...awsRequestStubMap, + S3: { + ...awsRequestStubMap.S3, + headBucket: headBucketStub, + }, + CloudFormation: { + ...awsRequestStubMap.CloudFormation, + describeStackResource: () => { + const err = new Error('Resource does not exist for stack new-service-dev'); + err.name = 'ValidationError'; + throw err; + }, + }, + }, + }); + + expect(headBucketStub).not.to.be.called; + expect(deleteObjectsStub).not.to.be.called; + expect(deleteStackStub).to.be.calledWithExactly({ StackName: awsNaming.getStackName() }); + expect(describeStackEventsStub).to.be.calledWithExactly({ + StackName: awsNaming.getStackName(), + }); + expect(describeStackEventsStub.calledAfter(deleteStackStub)).to.be.true; + }); + + it('rethrows unexpected SDK v3 deployment bucket lookup validation errors', async () => { + await expect( + runServerless({ + fixture: 'function', + command: 'remove', + awsRequestStubMap: { + ...awsRequestStubMap, + CloudFormation: { + ...awsRequestStubMap.CloudFormation, + describeStackResource: () => { + const err = new Error('Some other validation failure'); + err.name = 'ValidationError'; + throw err; + }, + }, + }, + }) + ).to.be.eventually.rejectedWith('Some other validation failure'); + }); + it('removes ECR repository if it exists', async () => { describeRepositoriesStub.resolves(); const { awsNaming } = await runServerless({ diff --git a/test/unit/lib/plugins/aws/utils/aws-sdk-v3-error.test.js b/test/unit/lib/plugins/aws/utils/aws-sdk-v3-error.test.js new file mode 100644 index 000000000..68a8630c5 --- /dev/null +++ b/test/unit/lib/plugins/aws/utils/aws-sdk-v3-error.test.js @@ -0,0 +1,155 @@ +'use strict'; + +const { expect } = require('chai'); +const awsSdkV3Error = require('../../../../../../lib/plugins/aws/utils/aws-sdk-v3-error'); + +describe('test/unit/lib/plugins/aws/utils/aws-sdk-v3-error.test.js', () => { + it('extracts error codes from legacy and SDK v3 error shapes', () => { + expect(awsSdkV3Error.getAwsErrorCode({ providerError: { code: 'LegacyCode' } })).to.equal( + 'LegacyCode' + ); + expect( + awsSdkV3Error.getAwsErrorCode({ providerError: { Code: 'UpperProviderCode' } }) + ).to.equal('UpperProviderCode'); + expect(awsSdkV3Error.getAwsErrorCode({ providerError: { name: 'ProviderNameCode' } })).to.equal( + 'ProviderNameCode' + ); + expect(awsSdkV3Error.getAwsErrorCode({ providerError: {}, code: 'FallbackCode' })).to.equal( + 'FallbackCode' + ); + expect(awsSdkV3Error.getAwsErrorCode({ Code: 'UpperCode' })).to.equal('UpperCode'); + expect(awsSdkV3Error.getAwsErrorCode({ code: 'LowerCode' })).to.equal('LowerCode'); + expect(Object.assign(new Error('boom'), { code: 'ErrorCode' })).to.have.property( + 'name', + 'Error' + ); + expect( + awsSdkV3Error.getAwsErrorCode(Object.assign(new Error('boom'), { code: 'ErrorCode' })) + ).to.equal('ErrorCode'); + expect(awsSdkV3Error.getAwsErrorCode({ name: 'NameCode' })).to.equal('NameCode'); + expect(awsSdkV3Error.getAwsErrorCode()).to.equal(undefined); + }); + + it('extracts status codes from legacy and SDK v3 error shapes', () => { + expect(awsSdkV3Error.getAwsErrorStatusCode({ providerError: { statusCode: 403 } })).to.equal( + 403 + ); + expect(awsSdkV3Error.getAwsErrorStatusCode({ $metadata: { httpStatusCode: 404 } })).to.equal( + 404 + ); + expect(awsSdkV3Error.getAwsErrorStatusCode({ statusCode: 429 })).to.equal(429); + expect(awsSdkV3Error.getAwsErrorStatusCode()).to.equal(undefined); + }); + + it('matches generic error codes and status codes', () => { + expect(awsSdkV3Error.isAwsErrorCode({ name: 'ValidationError' }, 'ValidationError')).to.equal( + true + ); + expect(awsSdkV3Error.isAwsErrorCode({ name: 'Other' }, 'ValidationError')).to.equal(false); + expect( + awsSdkV3Error.isAwsErrorStatusCode({ $metadata: { httpStatusCode: 403 } }, 403) + ).to.equal(true); + expect( + awsSdkV3Error.isAwsErrorStatusCode({ $metadata: { httpStatusCode: 404 } }, 403) + ).to.equal(false); + }); + + it('extracts and normalizes S3 bucket regions from SDK v3 response and error shapes', () => { + expect(awsSdkV3Error.getS3BucketRegion({ BucketRegion: 'us-east-1' })).to.equal('us-east-1'); + expect( + awsSdkV3Error.getS3BucketRegion({ + $metadata: { httpHeaders: { 'x-amz-bucket-region': 'us-west-2' } }, + }) + ).to.equal('us-west-2'); + expect( + awsSdkV3Error.getS3BucketRegion({ + $response: { headers: { 'X-Amz-Bucket-Region': 'eu-central-1' } }, + }) + ).to.equal('eu-central-1'); + expect(awsSdkV3Error.getS3BucketRegion({ BucketRegion: 'EU' })).to.equal('eu-west-1'); + expect(awsSdkV3Error.getS3BucketRegion()).to.equal(undefined); + }); + + it('matches S3 ListObjectsV2 missing-bucket and access-denied shapes', () => { + expect( + awsSdkV3Error.isS3ListObjectsNoSuchBucketError({ + code: 'AWS_S3_LIST_OBJECTS_V2_NO_SUCH_BUCKET', + }) + ).to.equal(true); + expect(awsSdkV3Error.isS3ListObjectsNoSuchBucketError({ name: 'NoSuchBucket' })).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsNoSuchBucketError({ $metadata: { httpStatusCode: 404 } }) + ).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsNoSuchBucketError({ + message: 'The specified bucket does not exist', + }) + ).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsAccessDeniedError({ + code: 'AWS_S3_LIST_OBJECTS_V2_ACCESS_DENIED', + }) + ).to.equal(true); + expect(awsSdkV3Error.isS3ListObjectsAccessDeniedError({ name: 'AccessDenied' })).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsAccessDeniedError({ $metadata: { httpStatusCode: 403 } }) + ).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsAccessDeniedError({ + providerError: { code: 'AccessDenied', statusCode: 403 }, + }) + ).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsAccessDeniedError({ + code: 'AWS_S3_LIST_OBJECTS_V2_ERROR', + providerError: { statusCode: 403 }, + }) + ).to.equal(true); + expect( + awsSdkV3Error.isS3ListObjectsAccessDeniedError({ + providerError: { code: 'SignatureDoesNotMatch', statusCode: 403 }, + }) + ).to.equal(false); + }); + + it('matches S3 HeadObject and HeadBucket shapes', () => { + expect(awsSdkV3Error.isS3HeadObjectForbiddenError({ name: 'Forbidden' })).to.equal(true); + expect( + awsSdkV3Error.isS3HeadObjectForbiddenError({ $metadata: { httpStatusCode: 403 } }) + ).to.equal(true); + expect(awsSdkV3Error.isS3HeadBucketNotFoundError({ name: 'NotFound' })).to.equal(true); + expect(awsSdkV3Error.isS3HeadBucketNotFoundError({ name: 'NoSuchBucket' })).to.equal(true); + expect( + awsSdkV3Error.isS3HeadBucketNotFoundError({ $metadata: { httpStatusCode: 404 } }) + ).to.equal(true); + expect(awsSdkV3Error.isS3HeadBucketForbiddenError({ name: 'AccessDenied' })).to.equal(true); + expect( + awsSdkV3Error.isS3HeadBucketForbiddenError({ $metadata: { httpStatusCode: 403 } }) + ).to.equal(true); + }); + + it('matches CloudFormation, Lambda, and ECR shapes', () => { + expect(awsSdkV3Error.isCloudFormationValidationError({ name: 'ValidationError' })).to.equal( + true + ); + expect(awsSdkV3Error.isLambdaAccessDeniedError({ name: 'AccessDeniedException' })).to.equal( + true + ); + expect( + awsSdkV3Error.isLambdaAccessDeniedError({ providerError: { statusCode: 403 } }) + ).to.equal(true); + expect( + awsSdkV3Error.isEcrRepositoryNotFoundError({ name: 'RepositoryNotFoundException' }) + ).to.equal(true); + expect(awsSdkV3Error.isEcrAccessDeniedError({ name: 'AccessDeniedException' })).to.equal(true); + expect( + awsSdkV3Error.isEcrAccessDeniedError({ + name: 'InvalidSignatureException', + $metadata: { httpStatusCode: 403 }, + }) + ).to.equal(false); + expect(awsSdkV3Error.isEcrAccessDeniedError({ $metadata: { httpStatusCode: 403 } })).to.equal( + false + ); + }); +}); diff --git a/test/unit/lib/plugins/aws/utils/resolve-cf-import-value.test.js b/test/unit/lib/plugins/aws/utils/resolve-cf-import-value.test.js index e0bf6e9d3..a27f955cb 100644 --- a/test/unit/lib/plugins/aws/utils/resolve-cf-import-value.test.js +++ b/test/unit/lib/plugins/aws/utils/resolve-cf-import-value.test.js @@ -2,24 +2,66 @@ const expect = require('chai').expect; const resolveCfImportValue = require('../../../../../../lib/plugins/aws/utils/resolve-cf-import-value'); +const { CloudFormationClient, ListExportsCommand } = require('@aws-sdk/client-cloudformation'); describe('#resolveCfImportValue', () => { + let listExportsStub; + const provider = { + getAwsSdkV3Config: async () => ({ region: 'us-east-1', credentials: async () => ({}) }), + }; + + beforeEach(() => { + listExportsStub = require('sinon').stub(CloudFormationClient.prototype, 'send'); + }); + + afterEach(() => { + CloudFormationClient.prototype.send.restore(); + }); + it('should return matching exported value if found', async () => { - const provider = { - request: async () => ({ - Exports: [ - { - Name: 'anotherName', - Value: 'anotherValue', - }, - { - Name: 'exportName', - Value: 'exportValue', - }, - ], - }), - }; + listExportsStub.resolves({ + Exports: [ + { + Name: 'anotherName', + Value: 'anotherValue', + }, + { + Name: 'exportName', + Value: 'exportValue', + }, + ], + }); + + const result = await resolveCfImportValue(provider, 'exportName'); + + expect(listExportsStub).to.have.been.calledOnce; + expect(listExportsStub.firstCall.args[0]).to.be.instanceOf(ListExportsCommand); + expect(listExportsStub.firstCall.args[0].input).to.deep.equal({}); + expect(result).to.equal('exportValue'); + }); + + it('should follow pagination tokens', async () => { + listExportsStub + .onFirstCall() + .resolves({ Exports: [{ Name: 'first', Value: 'firstValue' }], NextToken: 'next' }) + .onSecondCall() + .resolves({ Exports: [{ Name: 'exportName', Value: 'exportValue' }] }); + const result = await resolveCfImportValue(provider, 'exportName'); + expect(result).to.equal('exportValue'); + expect(listExportsStub).to.have.been.calledTwice; + expect(listExportsStub.secondCall.args[0].input).to.deep.equal({ NextToken: 'next' }); + }); + + it('should reject if export cannot be found', async () => { + listExportsStub.resolves({ Exports: [{ Name: 'first', Value: 'firstValue' }] }); + + try { + await resolveCfImportValue(provider, 'missing'); + throw new Error('Expected resolveCfImportValue to reject'); + } catch (error) { + expect(error.code).to.equal('CF_IMPORT_RESOLUTION'); + } }); }); diff --git a/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js b/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js new file mode 100644 index 000000000..4ff13cf07 --- /dev/null +++ b/test/unit/test-lib/configure-aws-sdk-v3-stub.test.js @@ -0,0 +1,113 @@ +'use strict'; + +const { expect } = require('chai'); +const configureAwsSdkV3Stub = require('../../lib/configure-aws-sdk-v3-stub'); + +describe('test/lib/configure-aws-sdk-v3-stub.test.js', () => { + it('stubs client commands and records send context', async () => { + const awsSdkV3Stub = configureAwsSdkV3Stub({ + S3: { + headBucket: { BucketRegion: 'us-east-1' }, + }, + }); + const { S3Client, HeadBucketCommand } = awsSdkV3Stub.modulesCacheStub['@aws-sdk/client-s3']; + + const client = new S3Client({ region: 'us-east-1' }); + const result = await client.send(new HeadBucketCommand({ Bucket: 'bucket' })); + + expect(result).to.deep.equal({ BucketRegion: 'us-east-1' }); + expect(awsSdkV3Stub.clients).to.have.length(1); + expect(awsSdkV3Stub.clients[0]).to.include({ service: 'S3', client }); + expect(awsSdkV3Stub.sends).to.have.length(1); + expect(awsSdkV3Stub.sends[0]).to.include({ + service: 'S3', + method: 'headBucket', + commandName: 'HeadBucketCommand', + client, + }); + expect(awsSdkV3Stub.sends[0].input).to.deep.equal({ Bucket: 'bucket' }); + }); + + it('routes paginator pages through client send with continuation tokens', async () => { + const awsSdkV3Stub = configureAwsSdkV3Stub({ + S3: { + listObjectsV2: [ + { Contents: [{ Key: 'first' }], NextContinuationToken: 'next-page' }, + { Contents: [{ Key: 'second' }] }, + ], + }, + }); + const { S3Client, ListObjectsV2Command, paginateListObjectsV2 } = + awsSdkV3Stub.modulesCacheStub['@aws-sdk/client-s3']; + const client = new S3Client({ region: 'us-east-1' }); + + const pages = []; + for await (const page of paginateListObjectsV2( + { client }, + { Bucket: 'bucket', Prefix: 'prefix' } + )) { + pages.push(page); + } + + expect(pages).to.deep.equal([ + { Contents: [{ Key: 'first' }], NextContinuationToken: 'next-page' }, + { Contents: [{ Key: 'second' }] }, + ]); + expect(awsSdkV3Stub.sends).to.have.length(2); + expect(awsSdkV3Stub.sends[0].command).to.be.instanceOf(ListObjectsV2Command); + expect(awsSdkV3Stub.sends[0].input).to.deep.equal({ + Bucket: 'bucket', + Prefix: 'prefix', + }); + expect(awsSdkV3Stub.sends[1].input).to.deep.equal({ + Bucket: 'bucket', + Prefix: 'prefix', + ContinuationToken: 'next-page', + }); + }); + + it('throws a clear error for missing method stubs', async () => { + const awsSdkV3Stub = configureAwsSdkV3Stub({ S3: {} }); + const { S3Client, HeadBucketCommand } = awsSdkV3Stub.modulesCacheStub['@aws-sdk/client-s3']; + const client = new S3Client({}); + + await expect(client.send(new HeadBucketCommand({ Bucket: 'bucket' }))).to.be.rejectedWith( + 'Missing AWS SDK v3 stub configuration for S3.headBucket' + ); + }); + + it('requires paginator config to include a client send function', async () => { + const awsSdkV3Stub = configureAwsSdkV3Stub({ + S3: { listObjectsV2: { Contents: [] } }, + }); + const { paginateListObjectsV2 } = awsSdkV3Stub.modulesCacheStub['@aws-sdk/client-s3']; + + await expect( + (async () => { + for await (const ignored of paginateListObjectsV2({}, { Bucket: 'bucket' })) { + void ignored; + } + })() + ).to.be.rejectedWith( + 'AWS SDK v3 stub paginator paginateListObjectsV2 requires config.client.send' + ); + }); + + it('rejects unsupported explicit services', () => { + expect(() => configureAwsSdkV3Stub({ Unsupported: { read: {} } })).to.throw( + 'Unsupported AWS SDK v3 stub service Unsupported' + ); + }); + + it('ignores unsupported services and methods in fallback mode', () => { + const awsSdkV3Stub = configureAwsSdkV3Stub( + { + Unsupported: { read: {} }, + Lambda: { invoke: {} }, + }, + { ignoreUnsupportedServices: true } + ); + + expect(awsSdkV3Stub.modulesCacheStub).to.deep.equal({}); + }); +});