Skip to content

Commit

Permalink
feat(cloudformation): aws-api custom resource (#1850)
Browse files Browse the repository at this point in the history
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
        }
      ]
    }
  }
})
```
  • Loading branch information
jogold authored and Elad Ben-Israel committed May 29, 2019
1 parent 436694f commit 9a48b66
Show file tree
Hide file tree
Showing 10 changed files with 1,539 additions and 24 deletions.
127 changes: 106 additions & 21 deletions packages/@aws-cdk/aws-cloudformation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
});
}
}
```

Expand All @@ -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: '...'
}
})
```
Original file line number Diff line number Diff line change
@@ -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);
}
});
}
}
Loading

0 comments on commit 9a48b66

Please sign in to comment.