From 9a48b66d1713577511f8edc8246c0c45a2f60ce4 Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Wed, 29 May 2019 17:29:37 +0200 Subject: [PATCH] feat(cloudformation): aws-api custom resource (#1850) This PR adds a CF custom resource to make calls on the AWS API using AWS SDK JS v2. There are lots of use cases when the CF coverage is not sufficient and adding a simple API call can solve the problem. It could be also used internally to create better L2 constructs. Does this fit in the scope of the cdk? If accepted, I think that ideally it should live in its own lerna package. API: ```ts new AwsSdkJsCustomResource(this, 'AwsSdk', { onCreate: { // AWS SDK call when resource is created (defaults to onUpdate) service: '...', action: '...', parameters: { ... } }. onUpdate: { ... }. // AWS SDK call when resource is updated (defaults to onCreate) onDelete: { ... }, // AWS SDK call when resource is deleted policyStatements: [...] // Automatically derived from the calls if not specified }); ``` Fargate scheduled task example (could be used in `@aws-cdk/aws-ecs` to implement the missing `FargateEventRuleTarget`): ```ts const vpc = ...; const cluster = new ecs.Cluster(...); const taskDefinition = new ecs.FargateTaskDefinition(...); const rule = new events.EventRule(this, 'Rule', { scheduleExpression: 'rate(1 hour)', }); const ruleRole = new iam.Role(...); new AwsSdkJsCustomResource(this, 'PutTargets', { onCreate: { service: 'CloudWatchEvents', action: 'putTargets', parameters: { Rule: rule.ruleName, Targets: [ Arn: cluster.clusterArn, Id: ..., EcsParameters: { taskDefinitionArn: taskDefinition.taskDefinitionArn, LaunchType: 'FARGATE', NetworkConfiguration: { awsvpcConfiguration: { AssignPublicIp: 'DISABLED', SecurityGroups: [...], Subnets: vpc.privateSubnets.map(subnet => subnet.subnetId), }, }, RoleArn: ruleRole.roleArn } ] } } }) ``` --- .../@aws-cdk/aws-cloudformation/README.md | 127 +++++- .../lib/aws-custom-resource-provider/index.ts | 103 +++++ .../lib/aws-custom-resource.ts | 181 ++++++++ .../@aws-cdk/aws-cloudformation/lib/index.ts | 1 + .../aws-cloudformation/package-lock.json | 410 ++++++++++++++++++ .../@aws-cdk/aws-cloudformation/package.json | 17 +- .../integ.aws-custom-resource.expected.json | 261 +++++++++++ .../test/integ.aws-custom-resource.ts | 48 ++ .../test/test.aws-custom-resource-provider.ts | 227 ++++++++++ .../test/test.aws-custom-resource.ts | 188 ++++++++ 10 files changed, 1539 insertions(+), 24 deletions(-) create mode 100644 packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource-provider/index.ts create mode 100644 packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource.ts create mode 100644 packages/@aws-cdk/aws-cloudformation/package-lock.json create mode 100644 packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.expected.json create mode 100644 packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.ts create mode 100644 packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource-provider.ts create mode 100644 packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource.ts diff --git a/packages/@aws-cdk/aws-cloudformation/README.md b/packages/@aws-cdk/aws-cloudformation/README.md index 20a82664f5aba..6f6367e28b110 100644 --- a/packages/@aws-cdk/aws-cloudformation/README.md +++ b/packages/@aws-cdk/aws-cloudformation/README.md @@ -30,30 +30,30 @@ Sample of a Custom Resource that copies files into an S3 bucket during deploymen ```ts interface CopyOperationProps { - sourceBucket: IBucket; - targetBucket: IBucket; + sourceBucket: IBucket; + targetBucket: IBucket; } class CopyOperation extends Construct { - constructor(parent: Construct, name: string, props: DemoResourceProps) { - super(parent, name); - - const lambdaProvider = new SingletonLambda(this, 'Provider', { - uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc', - code: new LambdaInlineCode(resources['copy.py']), - handler: 'index.handler', - timeout: 60, - runtime: LambdaRuntime.Python3, - }); - - new CustomResource(this, 'Resource', { - lambdaProvider, - properties: { - sourceBucketArn: props.sourceBucket.bucketArn, - targetBucketArn: props.targetBucket.bucketArn, - } - }); - } + constructor(parent: Construct, name: string, props: DemoResourceProps) { + super(parent, name); + + const lambdaProvider = new SingletonLambda(this, 'Provider', { + uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc', + code: new LambdaInlineCode(resources['copy.py']), + handler: 'index.handler', + timeout: 60, + runtime: LambdaRuntime.Python3, + }); + + new CustomResource(this, 'Resource', { + provider: CustomResourceProvider.lambda(provider), + properties: { + sourceBucketArn: props.sourceBucket.bucketArn, + targetBucketArn: props.targetBucket.bucketArn, + } + }); + } } ``` @@ -67,3 +67,88 @@ See the following section of the docs on details to write Custom Resources: * [Introduction](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html) * [Reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref.html) * [Code Reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html) + +#### AWS Custom Resource +Sometimes a single API call can fill the gap in the CloudFormation coverage. In +this case you can use the `AwsCustomResource` construct. This construct creates +a custom resource that can be customized to make specific API calls for the +`CREATE`, `UPDATE` and `DELETE` events. Additionally, data returned by the API +call can be extracted and used in other constructs/resources (creating a real +CloudFormation dependency using `Fn::GetAtt` under the hood). + +The physical id of the custom resource can be specified or derived from the data +return by the API call. + +The `AwsCustomResource` uses the AWS SDK for JavaScript. Services, actions and +parameters can be found in the [API documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html). + +Path to data must be specified using a dot notation, e.g. to get the string value +of the `Title` attribute for the first item returned by `dynamodb.query` it should +be `Items.0.Title.S`. + +##### Examples +Verify a domain with SES: + +```ts +const verifyDomainIdentity = new AwsCustomResource(this, 'VerifyDomainIdentity', { + onCreate: { + service: 'SES', + action: 'verifyDomainIdentity', + parameters: { + Domain: 'example.com' + }, + physicalResourceIdPath: 'VerificationToken' // Use the token returned by the call as physical id + } +}); + +new route53.TxtRecord(zone, 'SESVerificationRecord', { + recordName: `_amazonses.example.com`, + recordValue: verifyDomainIdentity.getData('VerificationToken') +}); +``` + +Get the latest version of a secure SSM parameter: + +```ts +const getParameter = new AwsCustomResource(this, 'GetParameter', { + onUpdate: { // will also be called for a CREATE event + service: 'SSM', + action: 'getParameter', + parameters: { + Name: 'my-parameter', + WithDecryption: true + }, + physicalResourceId: Date.now().toString() // Update physical id to always fetch the latest version + } +}); + +// Use the value in another construct with +getParameter.getData('Parameter.Value') +``` + +IAM policy statements required to make the API calls are derived from the calls +and allow by default the actions to be made on all resources (`*`). You can +restrict the permissions by specifying your own list of statements with the +`policyStatements` prop. + +Chained API calls can be achieved by creating dependencies: +```ts +const awsCustom1 = new AwsCustomResource(this, 'API1', { + onCreate: { + service: '...', + action: '...', + physicalResourceId: '...' + } +}); + +const awsCustom2 = new AwsCustomResource(this, 'API2', { + onCreate: { + service: '...', + action: '...' + parameters: { + text: awsCustom1.getData('Items.0.text') + }, + physicalResourceId: '...' + } +}) +``` diff --git a/packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource-provider/index.ts b/packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource-provider/index.ts new file mode 100644 index 0000000000000..5dcf0d9c2e14f --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource-provider/index.ts @@ -0,0 +1,103 @@ +// tslint:disable:no-console +import AWS = require('aws-sdk'); +import { AwsSdkCall } from '../aws-custom-resource'; + +/** + * Flattens a nested object + * + * @param object the object to be flattened + * @returns a flat object with path as keys + */ +function flatten(object: object): { [key: string]: string } { + return Object.assign( + {}, + ...function _flatten(child: any, path: string[] = []): any { + return [].concat(...Object.keys(child) + .map(key => + typeof child[key] === 'object' + ? _flatten(child[key], path.concat([key])) + : ({ [path.concat([key]).join('.')]: child[key] }) + )); + }(object) + ); +} + +/** + * Converts true/false strings to booleans in an object + */ +function fixBooleans(object: object) { + return JSON.parse(JSON.stringify(object), (_k, v) => v === 'true' + ? true + : v === 'false' + ? false + : v); +} + +export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) { + try { + console.log(JSON.stringify(event)); + console.log('AWS SDK VERSION: ' + (AWS as any).VERSION); + + let physicalResourceId = (event as any).PhysicalResourceId; + let data: { [key: string]: string } = {}; + const call: AwsSdkCall | undefined = event.ResourceProperties[event.RequestType]; + + if (call) { + const awsService = new (AWS as any)[call.service](call.apiVersion && { apiVersion: call.apiVersion }); + + try { + const response = await awsService[call.action](call.parameters && fixBooleans(call.parameters)).promise(); + data = flatten(response); + } catch (e) { + if (!call.catchErrorPattern || !new RegExp(call.catchErrorPattern).test(e.code)) { + throw e; + } + } + + if (call.physicalResourceIdPath) { + physicalResourceId = data[call.physicalResourceIdPath]; + } else { + physicalResourceId = call.physicalResourceId!; + } + } + + await respond('SUCCESS', 'OK', physicalResourceId, data); + } catch (e) { + console.log(e); + await respond('FAILED', e.message, context.logStreamName, {}); + } + + function respond(responseStatus: string, reason: string, physicalResourceId: string, data: any) { + const responseBody = JSON.stringify({ + Status: responseStatus, + Reason: reason, + PhysicalResourceId: physicalResourceId, + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + NoEcho: false, + Data: data + }); + + console.log('Responding', responseBody); + + const parsedUrl = require('url').parse(event.ResponseURL); + const requestOptions = { + hostname: parsedUrl.hostname, + path: parsedUrl.path, + method: 'PUT', + headers: { 'content-type': '', 'content-length': responseBody.length } + }; + + return new Promise((resolve, reject) => { + try { + const request = require('https').request(requestOptions, resolve); + request.on('error', reject); + request.write(responseBody); + request.end(); + } catch (e) { + reject(e); + } + }); + } +} diff --git a/packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource.ts b/packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource.ts new file mode 100644 index 0000000000000..7a0b7b657a8a8 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/lib/aws-custom-resource.ts @@ -0,0 +1,181 @@ +import iam = require('@aws-cdk/aws-iam'); +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import metadata = require('aws-sdk/apis/metadata.json'); +import path = require('path'); +import { CustomResource, CustomResourceProvider } from './custom-resource'; + +/** + * AWS SDK service metadata. + */ +export type AwsSdkMetadata = {[key: string]: any}; + +const awsSdkMetadata: AwsSdkMetadata = metadata; + +/** + * An AWS SDK call. + */ +export interface AwsSdkCall { + /** + * The service to call + * + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html + */ + readonly service: string; + + /** + * The service action to call + * + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html + */ + readonly action: string; + + /** + * The parameters for the service action + * + * @see https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html + */ + readonly parameters?: any; + + /** + * The path to the data in the API call response to use as the physical + * resource id. Either `physicalResourceId` or `physicalResourceIdPath` + * must be specified for onCreate or onUpdate calls. + * + * @default no path + */ + readonly physicalResourceIdPath?: string; + + /** + * The physical resource id of the custom resource for this call. Either + * `physicalResourceId` or `physicalResourceIdPath` must be specified for + * onCreate or onUpdate calls. + * + * @default no physical resource id + */ + readonly physicalResourceId?: string; + + /** + * The regex pattern to use to catch API errors. The `code` property of the + * `Error` object will be tested against this pattern. If there is a match an + * error will not be thrown. + * + * @default do not catch errors + */ + readonly catchErrorPattern?: string; + + /** + * API version to use for the service + * + * @see https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/locking-api-versions.html + * @default use latest available API version + */ + readonly apiVersion?: string; +} + +export interface AwsCustomResourceProps { + /** + * The AWS SDK call to make when the resource is created. + * At least onCreate, onUpdate or onDelete must be specified. + * + * @default the call when the resource is updated + */ + readonly onCreate?: AwsSdkCall; + + /** + * The AWS SDK call to make when the resource is updated + * + * @default no call + */ + readonly onUpdate?: AwsSdkCall; + + /** + * THe AWS SDK call to make when the resource is deleted + * + * @default no call + */ + readonly onDelete?: AwsSdkCall; + + /** + * The IAM policy statements to allow the different calls. Use only if + * resource restriction is needed. + * + * @default extract the permissions from the calls + */ + readonly policyStatements?: iam.PolicyStatement[]; +} + +export class AwsCustomResource extends cdk.Construct { + private readonly customResource: CustomResource; + + constructor(scope: cdk.Construct, id: string, props: AwsCustomResourceProps) { + super(scope, id); + + if (!props.onCreate && !props.onUpdate && !props.onDelete) { + throw new Error('At least `onCreate`, `onUpdate` or `onDelete` must be specified.'); + } + + for (const call of [props.onCreate, props.onUpdate]) { + if (call && !call.physicalResourceId && !call.physicalResourceIdPath) { + throw new Error('Either `physicalResourceId` or `physicalResourceIdPath` must be specified for onCreate and onUpdate calls.'); + } + } + + const provider = new lambda.SingletonFunction(this, 'Provider', { + code: lambda.Code.asset(path.join(__dirname, 'aws-custom-resource-provider')), + runtime: lambda.Runtime.NodeJS810, + handler: 'index.handler', + uuid: '679f53fa-c002-430c-b0da-5b7982bd2287', + lambdaPurpose: 'AWS' + }); + + if (props.policyStatements) { + for (const statement of props.policyStatements) { + provider.addToRolePolicy(statement); + } + } else { // Derive statements from AWS SDK calls + for (const call of [props.onCreate, props.onUpdate, props.onDelete]) { + if (call) { + provider.addToRolePolicy( + new iam.PolicyStatement() + .addAction(awsSdkToIamAction(call.service, call.action)) + .addAllResources() + ); + } + } + } + + this.customResource = new CustomResource(this, 'Resource', { + resourceType: 'Custom::AWS', + provider: CustomResourceProvider.lambda(provider), + properties: { + create: props.onCreate || props.onUpdate, + update: props.onUpdate, + delete: props.onDelete + } + }); + } + + /** + * Returns response data for the AWS SDK call. + * Example for S3 / listBucket : 'Buckets.0.Name' + * + * @param dataPath the path to the data + */ + public getData(dataPath: string) { + return this.customResource.getAtt(dataPath); + } +} + +/** + * Transform SDK service/action to IAM action using metadata from aws-sdk module. + * Example: CloudWatchLogs with putRetentionPolicy => logs:PutRetentionPolicy + * + * TODO: is this mapping correct for all services? + */ +function awsSdkToIamAction(service: string, action: string): string { + const srv = service.toLowerCase(); + const iamService = awsSdkMetadata[srv].prefix || srv; + const iamAction = action.charAt(0).toUpperCase() + action.slice(1); + return `${iamService}:${iamAction}`; +} diff --git a/packages/@aws-cdk/aws-cloudformation/lib/index.ts b/packages/@aws-cdk/aws-cloudformation/lib/index.ts index 3d4971d16542b..d8bf5b68fb613 100644 --- a/packages/@aws-cdk/aws-cloudformation/lib/index.ts +++ b/packages/@aws-cdk/aws-cloudformation/lib/index.ts @@ -1,5 +1,6 @@ export * from './cloud-formation-capabilities'; export * from './custom-resource'; +export * from './aws-custom-resource'; // AWS::CloudFormation CloudFormation Resources: export * from './cloudformation.generated'; diff --git a/packages/@aws-cdk/aws-cloudformation/package-lock.json b/packages/@aws-cdk/aws-cloudformation/package-lock.json new file mode 100644 index 0000000000000..a0c9d0fd3c078 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/package-lock.json @@ -0,0 +1,410 @@ +{ + "name": "@aws-cdk/aws-cloudformation", + "version": "0.32.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@sinonjs/commons": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.4.0.tgz", + "integrity": "sha512-9jHK3YF/8HtJ9wCAbG+j8cD0i0+ATS9A7gXFqS36TblLPNy6rEEc+SB0imo91eCboGaBYGV/MT1/br/J+EE7Tw==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/formatio": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.2.1.tgz", + "integrity": "sha512-tsHvOB24rvyvV2+zKMmPkZ7dXX6LSLKZ7aOtXY6Edklp0uRcgGpOsQTTGTcWViFyx4uhWc6GV8QdnALbIbIdeQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1", + "@sinonjs/samsam": "^3.1.0" + } + }, + "@sinonjs/samsam": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.3.1.tgz", + "integrity": "sha512-wRSfmyd81swH0hA1bxJZJ57xr22kC07a1N4zuIL47yTS04bDk6AoCkczcqHEjcRPmJ+FruGJ9WBQiJwMtIElFw==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.0.2", + "array-from": "^2.1.1", + "lodash": "^4.17.11" + } + }, + "@sinonjs/text-encoding": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.1.tgz", + "integrity": "sha512-+iTbntw2IZPb/anVDbypzfQa+ay64MW0Zo8aJ8gZPWMMK6/OubMVb6lUPMagqjOPnmtauXnFCACVl3O7ogjeqQ==", + "dev": true + }, + "@types/aws-lambda": { + "version": "8.10.23", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.23.tgz", + "integrity": "sha512-erfexxfuc1+T7b4OswooKwpIjpdgEOVz6ZrDDWSR+3v7Kjhs4EVowfUkF9KuLKhpcjz+VVHQ/pWIl7zSVbKbFQ==", + "dev": true + }, + "@types/nock": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@types/nock/-/nock-9.3.1.tgz", + "integrity": "sha512-eOVHXS5RnWOjTVhu3deCM/ruy9E6JCgeix2g7wpFiekQh3AaEAK1cz43tZDukKmtSmQnwvSySq7ubijCA32I7Q==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/node": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-11.12.0.tgz", + "integrity": "sha512-Lg00egj78gM+4aE0Erw05cuDbvX9sLJbaaPwwRtdCdAMnIudqrQZ0oZX98Ek0yiSK/A2nubHgJfvII/rTT2Dwg==", + "dev": true + }, + "@types/sinon": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-7.0.10.tgz", + "integrity": "sha512-4w7SvsiUOtd4mUfund9QROPSJ5At/GQskDpqd87pJIRI6ULWSJqHI3GIZE337wQuN3aznroJGr94+o8fwvL37Q==", + "dev": true + }, + "array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", + "dev": true + }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, + "aws-sdk": { + "version": "2.409.0", + "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.409.0.tgz", + "integrity": "sha512-QV6j9zBQq/Kz8BqVOrJ03ABjMKtErXdUT1YdYEljoLQZimpzt0ZdQwJAsoZIsxxriOJgrqeZsQUklv9AFQaldQ==", + "requires": { + "buffer": "4.9.1", + "events": "1.1.1", + "ieee754": "1.1.8", + "jmespath": "0.15.0", + "querystring": "0.2.0", + "sax": "1.2.1", + "url": "0.10.3", + "uuid": "3.3.2", + "xml2js": "0.4.19" + } + }, + "aws-sdk-mock": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/aws-sdk-mock/-/aws-sdk-mock-4.3.1.tgz", + "integrity": "sha512-uOaf7/Tq9kSoRc2/EQfAn24AAwU6UwvR8xSFSg0vTRxK0xHHEZ5UB/KF6ibF2gj0I4977lM35237E5sbzhRxKA==", + "dev": true, + "requires": { + "aws-sdk": "^2.369.0", + "sinon": "^7.1.1", + "traverse": "^0.6.6" + } + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==" + }, + "buffer": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "chai": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.2.0.tgz", + "integrity": "sha512-XQU3bhBukrOsQCuwZndwGcCVQHyZi53fQ6Ys1Fym7E4olpIqqZZhhoFJoaKVvV17lWQoXYwgWN2nF5crA8J2jw==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.0", + "type-detect": "^4.0.5" + } + }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=", + "dev": true + }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=" + }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "ieee754": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.8.tgz", + "integrity": "sha1-vjPUCsEO8ZJnAfbwii2G+/0a0+Q=" + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "jmespath": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz", + "integrity": "sha1-o/Iiqarp+Wb10nx5ZRDigJF2Qhc=" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "just-extend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz", + "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", + "dev": true + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "lolex": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.1.0.tgz", + "integrity": "sha512-zFo5MgCJ0rZ7gQg69S4pqBsLURbFw11X68C18OcJjJQbqaXm2NoTrGl1IMM3TIz0/BnN1tIs2tzmmqvCsOMMjw==", + "dev": true + }, + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "nise": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.10.tgz", + "integrity": "sha512-sa0RRbj53dovjc7wombHmVli9ZihXbXCQ2uH3TNm03DyvOSIQbxg+pbqDKrk2oxMK1rtLGVlKxcB9rrc6X5YjA==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^3.1.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "lolex": "^2.3.2", + "path-to-regexp": "^1.7.0" + }, + "dependencies": { + "lolex": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", + "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", + "dev": true + } + } + }, + "nock": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/nock/-/nock-10.0.6.tgz", + "integrity": "sha512-b47OWj1qf/LqSQYnmokNWM8D88KvUl2y7jT0567NB3ZBAZFz2bWp2PC81Xn7u8F2/vJxzkzNZybnemeFa7AZ2w==", + "dev": true, + "requires": { + "chai": "^4.1.2", + "debug": "^4.1.0", + "deep-equal": "^1.0.0", + "json-stringify-safe": "^5.0.1", + "lodash": "^4.17.5", + "mkdirp": "^0.5.0", + "propagate": "^1.0.0", + "qs": "^6.5.1", + "semver": "^5.5.0" + } + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + } + } + }, + "pathval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.0.tgz", + "integrity": "sha1-uULm1L3mUwBe9rcTYd74cn0GReA=", + "dev": true + }, + "propagate": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-1.0.0.tgz", + "integrity": "sha1-AMLa7t2iDofjeCs0Stuhzd1q1wk=", + "dev": true + }, + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=" + }, + "qs": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.7.0.tgz", + "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==", + "dev": true + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=" + }, + "sax": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", + "integrity": "sha1-e45lYZCyKOgaZq6nSEgNgozS03o=" + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true + }, + "sinon": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.3.1.tgz", + "integrity": "sha512-eQKMaeWovtOtYe2xThEvaHmmxf870Di+bim10c3ZPrL5bZhLGtu8cz+rOBTFz0CwBV4Q/7dYwZiqZbGVLZ+vjQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.4.0", + "@sinonjs/formatio": "^3.2.1", + "@sinonjs/samsam": "^3.3.1", + "diff": "^3.5.0", + "lolex": "^3.1.0", + "nise": "^1.4.10", + "supports-color": "^5.5.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=", + "dev": true + }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, + "url": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", + "integrity": "sha1-Ah5NnHcF8hu/N9A861h2dAJ3TGQ=", + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + } + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==" + }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + } + } +} diff --git a/packages/@aws-cdk/aws-cloudformation/package.json b/packages/@aws-cdk/aws-cloudformation/package.json index f9f404c601406..b118926a0cc03 100644 --- a/packages/@aws-cdk/aws-cloudformation/package.json +++ b/packages/@aws-cdk/aws-cloudformation/package.json @@ -62,17 +62,27 @@ "devDependencies": { "@aws-cdk/assert": "^0.32.0", "@aws-cdk/aws-events": "^0.32.0", + "@types/aws-lambda": "^8.10.23", + "@types/nock": "^9.3.1", + "@types/sinon": "^7.0.10", + "aws-sdk-mock": "^4.3.1", "cdk-build-tools": "^0.32.0", "cdk-integ-tools": "^0.32.0", "cfn2ts": "^0.32.0", - "pkglint": "^0.32.0" + "nock": "^10.0.6", + "pkglint": "^0.32.0", + "sinon": "^7.3.1" }, "dependencies": { "@aws-cdk/aws-iam": "^0.32.0", "@aws-cdk/aws-lambda": "^0.32.0", "@aws-cdk/aws-sns": "^0.32.0", - "@aws-cdk/cdk": "^0.32.0" + "@aws-cdk/cdk": "^0.32.0", + "aws-sdk": "^2.409.0" }, + "bundledDependencies": [ + "aws-sdk" + ], "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { "@aws-cdk/aws-iam": "^0.32.0", @@ -86,7 +96,8 @@ "awslint": { "exclude": [ "construct-ctor:@aws-cdk/aws-cloudformation.PipelineCloudFormationAction.", - "construct-ctor:@aws-cdk/aws-cloudformation.PipelineCloudFormationDeployAction." + "construct-ctor:@aws-cdk/aws-cloudformation.PipelineCloudFormationDeployAction.", + "construct-ctor-props-optional:@aws-cdk/aws-cloudformation.AwsCustomResource" ] } } diff --git a/packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.expected.json b/packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.expected.json new file mode 100644 index 0000000000000..2ed3d93227ae6 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.expected.json @@ -0,0 +1,261 @@ +{ + "Resources": { + "TopicBFC7AF6E": { + "Type": "AWS::SNS::Topic" + }, + "Publish2E9BDF73": { + "Type": "Custom::AWS", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd22872D164C4C", + "Arn" + ] + }, + "Create": { + "service": "SNS", + "action": "publish", + "parameters": { + "Message": "hello", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + }, + "physicalResourceId": { + "Ref": "TopicBFC7AF6E" + } + }, + "Update": { + "service": "SNS", + "action": "publish", + "parameters": { + "Message": "hello", + "TopicArn": { + "Ref": "TopicBFC7AF6E" + } + }, + "physicalResourceId": { + "Ref": "TopicBFC7AF6E" + } + } + } + }, + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "lambda.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleDefaultPolicyD28E1A5E": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "sns:ListTopics", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "ssm:GetParameter", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleDefaultPolicyD28E1A5E", + "Roles": [ + { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + } + ] + } + }, + "AWS679f53fac002430cb0da5b7982bd22872D164C4C": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "S3Bucket": { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287CodeS3BucketF55839B6" + }, + "S3Key": { + "Fn::Join": [ + "", + [ + { + "Fn::Select": [ + 0, + { + "Fn::Split": [ + "||", + { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287CodeS3VersionKey3C45B02F" + } + ] + } + ] + }, + { + "Fn::Select": [ + 1, + { + "Fn::Split": [ + "||", + { + "Ref": "AWS679f53fac002430cb0da5b7982bd2287CodeS3VersionKey3C45B02F" + } + ] + } + ] + } + ] + ] + } + }, + "Handler": "index.handler", + "Role": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2", + "Arn" + ] + }, + "Runtime": "nodejs8.10" + }, + "DependsOn": [ + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleDefaultPolicyD28E1A5E", + "AWS679f53fac002430cb0da5b7982bd2287ServiceRoleC1EA0FF2" + ] + }, + "ListTopicsCE1E0341": { + "Type": "Custom::AWS", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd22872D164C4C", + "Arn" + ] + }, + "Create": { + "service": "SNS", + "action": "listTopics", + "physicalResourceIdPath": "Topics.0.TopicArn" + }, + "Update": { + "service": "SNS", + "action": "listTopics", + "physicalResourceIdPath": "Topics.0.TopicArn" + } + } + }, + "GetParameter42B0A00E": { + "Type": "Custom::AWS", + "Properties": { + "ServiceToken": { + "Fn::GetAtt": [ + "AWS679f53fac002430cb0da5b7982bd22872D164C4C", + "Arn" + ] + }, + "Create": { + "service": "SSM", + "action": "getParameter", + "parameters": { + "Name": "my-parameter", + "WithDecryption": true + }, + "physicalResourceIdPath": "Parameter.ARN" + }, + "Update": { + "service": "SSM", + "action": "getParameter", + "parameters": { + "Name": "my-parameter", + "WithDecryption": true + }, + "physicalResourceIdPath": "Parameter.ARN" + } + } + } + }, + "Parameters": { + "AWS679f53fac002430cb0da5b7982bd2287CodeS3BucketF55839B6": { + "Type": "String", + "Description": "S3 bucket for asset \"aws-cdk-sdk-js/AWS679f53fac002430cb0da5b7982bd2287/Code\"" + }, + "AWS679f53fac002430cb0da5b7982bd2287CodeS3VersionKey3C45B02F": { + "Type": "String", + "Description": "S3 key for asset version \"aws-cdk-sdk-js/AWS679f53fac002430cb0da5b7982bd2287/Code\"" + }, + "AWS679f53fac002430cb0da5b7982bd2287CodeArtifactHash49FACC2E": { + "Type": "String", + "Description": "Artifact hash for asset \"aws-cdk-sdk-js/AWS679f53fac002430cb0da5b7982bd2287/Code\"" + } + }, + "Outputs": { + "MessageId": { + "Value": { + "Fn::GetAtt": [ + "Publish2E9BDF73", + "MessageId" + ] + } + }, + "TopicArn": { + "Value": { + "Fn::GetAtt": [ + "ListTopicsCE1E0341", + "Topics.0.TopicArn" + ] + } + }, + "ParameterValue": { + "Value": { + "Fn::GetAtt": [ + "GetParameter42B0A00E", + "Parameter.Value" + ] + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.ts b/packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.ts new file mode 100644 index 0000000000000..92d707ebc1ea6 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/integ.aws-custom-resource.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node +import sns = require('@aws-cdk/aws-sns'); +import cdk = require('@aws-cdk/cdk'); +import { AwsCustomResource } from '../lib'; + +const app = new cdk.App(); + +const stack = new cdk.Stack(app, 'aws-cdk-sdk-js'); + +const topic = new sns.Topic(stack, 'Topic'); + +const snsPublish = new AwsCustomResource(stack, 'Publish', { + onUpdate: { + service: 'SNS', + action: 'publish', + parameters: { + Message: 'hello', + TopicArn: topic.topicArn + }, + physicalResourceId: topic.topicArn, + } +}); + +const listTopics = new AwsCustomResource(stack, 'ListTopics', { + onUpdate: { + service: 'SNS', + action: 'listTopics', + physicalResourceIdPath: 'Topics.0.TopicArn' + } +}); + +const getParameter = new AwsCustomResource(stack, 'GetParameter', { + onUpdate: { + service: 'SSM', + action: 'getParameter', + parameters: { + Name: 'my-parameter', + WithDecryption: true + }, + physicalResourceIdPath: 'Parameter.ARN' + } +}); + +new cdk.CfnOutput(stack, 'MessageId', { value: snsPublish.getData('MessageId') }); +new cdk.CfnOutput(stack, 'TopicArn', { value: listTopics.getData('Topics.0.TopicArn') }); +new cdk.CfnOutput(stack, 'ParameterValue', { value: getParameter.getData('Parameter.Value') }); + +app.run(); diff --git a/packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource-provider.ts b/packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource-provider.ts new file mode 100644 index 0000000000000..a28f7035acad3 --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource-provider.ts @@ -0,0 +1,227 @@ +import SDK = require('aws-sdk'); +import AWS = require('aws-sdk-mock'); +import nock = require('nock'); +import { Test } from 'nodeunit'; +import sinon = require('sinon'); +import { AwsSdkCall } from '../lib'; +import { handler } from '../lib/aws-custom-resource-provider'; + +const eventCommon = { + ServiceToken: 'token', + ResponseURL: 'https://localhost', + StackId: 'stackId', + RequestId: 'requestId', + LogicalResourceId: 'logicalResourceId', + ResourceType: 'Custom::AWS', +}; + +function createRequest(bodyPredicate: (body: AWSLambda.CloudFormationCustomResourceResponse) => boolean) { + return nock('https://localhost') + .put('/', bodyPredicate) + .reply(200); +} + +export = { + 'tearDown'(callback: any) { + AWS.restore(); + nock.cleanAll(); + callback(); + }, + + async 'create event with physical resource id path'(test: Test) { + const listObjectsFake = sinon.fake.resolves({ + Contents: [ + { + Key: 'first-key', + ETag: 'first-key-etag' + }, + { + Key: 'second-key', + ETag: 'second-key-etag', + } + ] + } as SDK.S3.ListObjectsOutput); + + AWS.mock('S3', 'listObjects', listObjectsFake); + + const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { + ...eventCommon, + RequestType: 'Create', + ResourceProperties: { + ServiceToken: 'token', + Create: { + service: 'S3', + action: 'listObjects', + parameters: { + Bucket: 'my-bucket' + }, + physicalResourceIdPath: 'Contents.1.ETag' + } as AwsSdkCall + } + }; + + const request = createRequest(body => + body.Status === 'SUCCESS' && + body.PhysicalResourceId === 'second-key-etag' && + body.Data!['Contents.0.Key'] === 'first-key' + ); + + await handler(event, {} as AWSLambda.Context); + + sinon.assert.calledWith(listObjectsFake, { + Bucket: 'my-bucket' + }); + + test.equal(request.isDone(), true); + + test.done(); + }, + + async 'update event with physical resource id'(test: Test) { + const publish = sinon.fake.resolves({}); + + AWS.mock('SNS', 'publish', publish); + + const event: AWSLambda.CloudFormationCustomResourceUpdateEvent = { + ...eventCommon, + RequestType: 'Update', + PhysicalResourceId: 'physicalResourceId', + OldResourceProperties: {}, + ResourceProperties: { + ServiceToken: 'token', + Update: { + service: 'SNS', + action: 'publish', + parameters: { + Message: 'hello', + TopicArn: 'topicarn' + }, + physicalResourceId: 'topicarn' + } as AwsSdkCall + } + }; + + const request = createRequest(body => + body.Status === 'SUCCESS' && + body.PhysicalResourceId === 'topicarn' + ); + + await handler(event, {} as AWSLambda.Context); + + test.equal(request.isDone(), true); + + test.done(); + }, + + async 'delete event'(test: Test) { + const listObjectsFake = sinon.fake.resolves({}); + + AWS.mock('S3', 'listObjects', listObjectsFake); + + const event: AWSLambda.CloudFormationCustomResourceDeleteEvent = { + ...eventCommon, + RequestType: 'Delete', + PhysicalResourceId: 'physicalResourceId', + ResourceProperties: { + ServiceToken: 'token', + Create: { + service: 'S3', + action: 'listObjects', + parameters: { + Bucket: 'my-bucket' + }, + physicalResourceIdPath: 'Contents.1.ETag' + } as AwsSdkCall + } + }; + + const request = createRequest(body => + body.Status === 'SUCCESS' && + body.PhysicalResourceId === 'physicalResourceId' && + Object.keys(body.Data!).length === 0 + ); + + await handler(event, {} as AWSLambda.Context); + + sinon.assert.notCalled(listObjectsFake); + + test.equal(request.isDone(), true); + + test.done(); + }, + + async 'catch errors'(test: Test) { + const error: NodeJS.ErrnoException = new Error(); + error.code = 'NoSuchBucket'; + const listObjectsFake = sinon.fake.rejects(error); + + AWS.mock('S3', 'listObjects', listObjectsFake); + + const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { + ...eventCommon, + RequestType: 'Create', + ResourceProperties: { + ServiceToken: 'token', + Create: { + service: 'S3', + action: 'listObjects', + parameters: { + Bucket: 'my-bucket' + }, + physicalResourceId: 'physicalResourceId', + catchErrorPattern: 'NoSuchBucket' + } as AwsSdkCall + } + }; + + const request = createRequest(body => + body.Status === 'SUCCESS' && + body.PhysicalResourceId === 'physicalResourceId' && + Object.keys(body.Data!).length === 0 + ); + + await handler(event, {} as AWSLambda.Context); + + test.equal(request.isDone(), true); + + test.done(); + }, + + async 'fixes booleans'(test: Test) { + const getParameterFake = sinon.fake.resolves({}); + + AWS.mock('SSM', 'getParameter', getParameterFake); + + const event: AWSLambda.CloudFormationCustomResourceCreateEvent = { + ...eventCommon, + RequestType: 'Create', + ResourceProperties: { + ServiceToken: 'token', + Create: { + service: 'SSM', + action: 'getParameter', + parameters: { + Name: 'my-parameter', + WithDecryption: 'true' + }, + physicalResourceId: 'my-parameter' + } as AwsSdkCall + } + }; + + const request = createRequest(body => + body.Status === 'SUCCESS' + ); + + await handler(event, {} as AWSLambda.Context); + + sinon.assert.calledWith(getParameterFake, { + Name: 'my-parameter', + WithDecryption: true // boolean + }); + + test.equal(request.isDone(), true); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource.ts b/packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource.ts new file mode 100644 index 0000000000000..43dd116a5f46e --- /dev/null +++ b/packages/@aws-cdk/aws-cloudformation/test/test.aws-custom-resource.ts @@ -0,0 +1,188 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import { AwsCustomResource } from '../lib'; + +// tslint:disable:object-literal-key-quotes + +export = { + 'aws sdk js custom resource with onCreate and onDelete'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new AwsCustomResource(stack, 'AwsSdk', { + onCreate: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + }, + physicalResourceId: 'loggroup' + }, + onDelete: { + service: 'CloudWatchLogs', + action: 'deleteRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + } + } + }); + + // THEN + expect(stack).to(haveResource('Custom::AWS', { + "Create": { + "service": "CloudWatchLogs", + "action": "putRetentionPolicy", + "parameters": { + "logGroupName": "/aws/lambda/loggroup", + "retentionInDays": 90 + }, + "physicalResourceId": "loggroup" + }, + "Delete": { + "service": "CloudWatchLogs", + "action": "deleteRetentionPolicy", + "parameters": { + "logGroupName": "/aws/lambda/loggroup" + } + } + })); + + expect(stack).to(haveResource('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ + { + "Action": "logs:PutRetentionPolicy", + "Effect": "Allow", + "Resource": "*" + }, + { + "Action": "logs:DeleteRetentionPolicy", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + })); + + test.done(); + }, + + 'onCreate defaults to onUpdate'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new AwsCustomResource(stack, 'AwsSdk', { + onUpdate: { + service: 's3', + action: 'putObject', + parameters: { + Bucket: 'my-bucket', + Key: 'my-key', + Body: 'my-body' + }, + physicalResourceIdPath: 'ETag' + }, + }); + + // THEN + expect(stack).to(haveResource('Custom::AWS', { + "Create": { + "service": "s3", + "action": "putObject", + "parameters": { + "Bucket": "my-bucket", + "Key": "my-key", + "Body": "my-body" + }, + "physicalResourceIdPath": "ETag" + }, + "Update": { + "service": "s3", + "action": "putObject", + "parameters": { + "Bucket": "my-bucket", + "Key": "my-key", + "Body": "my-body" + }, + "physicalResourceIdPath": "ETag" + }, + })); + + test.done(); + }, + + 'with custom policyStatements'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new AwsCustomResource(stack, 'AwsSdk', { + onUpdate: { + service: 'S3', + action: 'putObject', + parameters: { + Bucket: 'my-bucket', + Key: 'my-key', + Body: 'my-body' + }, + physicalResourceIdPath: 'ETag' + }, + policyStatements: [ + new iam.PolicyStatement() + .addAction('s3:PutObject') + .addResource('arn:aws:s3:::my-bucket/my-key') + ] + }); + + // THEN + expect(stack).to(haveResource('AWS::IAM::Policy', { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3:PutObject", + "Effect": "Allow", + "Resource": "arn:aws:s3:::my-bucket/my-key" + }, + ], + "Version": "2012-10-17" + }, + })); + + test.done(); + }, + + 'fails when no calls are specified'(test: Test) { + const stack = new cdk.Stack(); + + test.throws(() => { + new AwsCustomResource(stack, 'AwsSdk', {}); + }, /`onCreate`.+`onUpdate`.+`onDelete`/); + + test.done(); + }, + + 'fails when no physical resource method is specified'(test: Test) { + const stack = new cdk.Stack(); + + test.throws(() => { + new AwsCustomResource(stack, 'AwsSdk', { + onUpdate: { + service: 'CloudWatchLogs', + action: 'putRetentionPolicy', + parameters: { + logGroupName: '/aws/lambda/loggroup', + retentionInDays: 90 + } + } + }); + }, /`physicalResourceId`.+`physicalResourceIdPath`/); + + test.done(); + } +};