Skip to content

Commit 9a48b66

Browse files
jogoldElad Ben-Israel
authored and
Elad Ben-Israel
committed
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 } ] } } }) ```
1 parent 436694f commit 9a48b66

10 files changed

+1539
-24
lines changed

packages/@aws-cdk/aws-cloudformation/README.md

+106-21
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,30 @@ Sample of a Custom Resource that copies files into an S3 bucket during deploymen
3030

3131
```ts
3232
interface CopyOperationProps {
33-
sourceBucket: IBucket;
34-
targetBucket: IBucket;
33+
sourceBucket: IBucket;
34+
targetBucket: IBucket;
3535
}
3636

3737
class CopyOperation extends Construct {
38-
constructor(parent: Construct, name: string, props: DemoResourceProps) {
39-
super(parent, name);
40-
41-
const lambdaProvider = new SingletonLambda(this, 'Provider', {
42-
uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc',
43-
code: new LambdaInlineCode(resources['copy.py']),
44-
handler: 'index.handler',
45-
timeout: 60,
46-
runtime: LambdaRuntime.Python3,
47-
});
48-
49-
new CustomResource(this, 'Resource', {
50-
lambdaProvider,
51-
properties: {
52-
sourceBucketArn: props.sourceBucket.bucketArn,
53-
targetBucketArn: props.targetBucket.bucketArn,
54-
}
55-
});
56-
}
38+
constructor(parent: Construct, name: string, props: DemoResourceProps) {
39+
super(parent, name);
40+
41+
const lambdaProvider = new SingletonLambda(this, 'Provider', {
42+
uuid: 'f7d4f730-4ee1-11e8-9c2d-fa7ae01bbebc',
43+
code: new LambdaInlineCode(resources['copy.py']),
44+
handler: 'index.handler',
45+
timeout: 60,
46+
runtime: LambdaRuntime.Python3,
47+
});
48+
49+
new CustomResource(this, 'Resource', {
50+
provider: CustomResourceProvider.lambda(provider),
51+
properties: {
52+
sourceBucketArn: props.sourceBucket.bucketArn,
53+
targetBucketArn: props.targetBucket.bucketArn,
54+
}
55+
});
56+
}
5757
}
5858
```
5959

@@ -67,3 +67,88 @@ See the following section of the docs on details to write Custom Resources:
6767
* [Introduction](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html)
6868
* [Reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/crpg-ref.html)
6969
* [Code Reference](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-lambda-function-code.html)
70+
71+
#### AWS Custom Resource
72+
Sometimes a single API call can fill the gap in the CloudFormation coverage. In
73+
this case you can use the `AwsCustomResource` construct. This construct creates
74+
a custom resource that can be customized to make specific API calls for the
75+
`CREATE`, `UPDATE` and `DELETE` events. Additionally, data returned by the API
76+
call can be extracted and used in other constructs/resources (creating a real
77+
CloudFormation dependency using `Fn::GetAtt` under the hood).
78+
79+
The physical id of the custom resource can be specified or derived from the data
80+
return by the API call.
81+
82+
The `AwsCustomResource` uses the AWS SDK for JavaScript. Services, actions and
83+
parameters can be found in the [API documentation](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/index.html).
84+
85+
Path to data must be specified using a dot notation, e.g. to get the string value
86+
of the `Title` attribute for the first item returned by `dynamodb.query` it should
87+
be `Items.0.Title.S`.
88+
89+
##### Examples
90+
Verify a domain with SES:
91+
92+
```ts
93+
const verifyDomainIdentity = new AwsCustomResource(this, 'VerifyDomainIdentity', {
94+
onCreate: {
95+
service: 'SES',
96+
action: 'verifyDomainIdentity',
97+
parameters: {
98+
Domain: 'example.com'
99+
},
100+
physicalResourceIdPath: 'VerificationToken' // Use the token returned by the call as physical id
101+
}
102+
});
103+
104+
new route53.TxtRecord(zone, 'SESVerificationRecord', {
105+
recordName: `_amazonses.example.com`,
106+
recordValue: verifyDomainIdentity.getData('VerificationToken')
107+
});
108+
```
109+
110+
Get the latest version of a secure SSM parameter:
111+
112+
```ts
113+
const getParameter = new AwsCustomResource(this, 'GetParameter', {
114+
onUpdate: { // will also be called for a CREATE event
115+
service: 'SSM',
116+
action: 'getParameter',
117+
parameters: {
118+
Name: 'my-parameter',
119+
WithDecryption: true
120+
},
121+
physicalResourceId: Date.now().toString() // Update physical id to always fetch the latest version
122+
}
123+
});
124+
125+
// Use the value in another construct with
126+
getParameter.getData('Parameter.Value')
127+
```
128+
129+
IAM policy statements required to make the API calls are derived from the calls
130+
and allow by default the actions to be made on all resources (`*`). You can
131+
restrict the permissions by specifying your own list of statements with the
132+
`policyStatements` prop.
133+
134+
Chained API calls can be achieved by creating dependencies:
135+
```ts
136+
const awsCustom1 = new AwsCustomResource(this, 'API1', {
137+
onCreate: {
138+
service: '...',
139+
action: '...',
140+
physicalResourceId: '...'
141+
}
142+
});
143+
144+
const awsCustom2 = new AwsCustomResource(this, 'API2', {
145+
onCreate: {
146+
service: '...',
147+
action: '...'
148+
parameters: {
149+
text: awsCustom1.getData('Items.0.text')
150+
},
151+
physicalResourceId: '...'
152+
}
153+
})
154+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// tslint:disable:no-console
2+
import AWS = require('aws-sdk');
3+
import { AwsSdkCall } from '../aws-custom-resource';
4+
5+
/**
6+
* Flattens a nested object
7+
*
8+
* @param object the object to be flattened
9+
* @returns a flat object with path as keys
10+
*/
11+
function flatten(object: object): { [key: string]: string } {
12+
return Object.assign(
13+
{},
14+
...function _flatten(child: any, path: string[] = []): any {
15+
return [].concat(...Object.keys(child)
16+
.map(key =>
17+
typeof child[key] === 'object'
18+
? _flatten(child[key], path.concat([key]))
19+
: ({ [path.concat([key]).join('.')]: child[key] })
20+
));
21+
}(object)
22+
);
23+
}
24+
25+
/**
26+
* Converts true/false strings to booleans in an object
27+
*/
28+
function fixBooleans(object: object) {
29+
return JSON.parse(JSON.stringify(object), (_k, v) => v === 'true'
30+
? true
31+
: v === 'false'
32+
? false
33+
: v);
34+
}
35+
36+
export async function handler(event: AWSLambda.CloudFormationCustomResourceEvent, context: AWSLambda.Context) {
37+
try {
38+
console.log(JSON.stringify(event));
39+
console.log('AWS SDK VERSION: ' + (AWS as any).VERSION);
40+
41+
let physicalResourceId = (event as any).PhysicalResourceId;
42+
let data: { [key: string]: string } = {};
43+
const call: AwsSdkCall | undefined = event.ResourceProperties[event.RequestType];
44+
45+
if (call) {
46+
const awsService = new (AWS as any)[call.service](call.apiVersion && { apiVersion: call.apiVersion });
47+
48+
try {
49+
const response = await awsService[call.action](call.parameters && fixBooleans(call.parameters)).promise();
50+
data = flatten(response);
51+
} catch (e) {
52+
if (!call.catchErrorPattern || !new RegExp(call.catchErrorPattern).test(e.code)) {
53+
throw e;
54+
}
55+
}
56+
57+
if (call.physicalResourceIdPath) {
58+
physicalResourceId = data[call.physicalResourceIdPath];
59+
} else {
60+
physicalResourceId = call.physicalResourceId!;
61+
}
62+
}
63+
64+
await respond('SUCCESS', 'OK', physicalResourceId, data);
65+
} catch (e) {
66+
console.log(e);
67+
await respond('FAILED', e.message, context.logStreamName, {});
68+
}
69+
70+
function respond(responseStatus: string, reason: string, physicalResourceId: string, data: any) {
71+
const responseBody = JSON.stringify({
72+
Status: responseStatus,
73+
Reason: reason,
74+
PhysicalResourceId: physicalResourceId,
75+
StackId: event.StackId,
76+
RequestId: event.RequestId,
77+
LogicalResourceId: event.LogicalResourceId,
78+
NoEcho: false,
79+
Data: data
80+
});
81+
82+
console.log('Responding', responseBody);
83+
84+
const parsedUrl = require('url').parse(event.ResponseURL);
85+
const requestOptions = {
86+
hostname: parsedUrl.hostname,
87+
path: parsedUrl.path,
88+
method: 'PUT',
89+
headers: { 'content-type': '', 'content-length': responseBody.length }
90+
};
91+
92+
return new Promise((resolve, reject) => {
93+
try {
94+
const request = require('https').request(requestOptions, resolve);
95+
request.on('error', reject);
96+
request.write(responseBody);
97+
request.end();
98+
} catch (e) {
99+
reject(e);
100+
}
101+
});
102+
}
103+
}

0 commit comments

Comments
 (0)