Skip to content

Commit 38aad67

Browse files
authored
feat(ecs): Option to encrypt lifecycle hook SNS Topic (#9343)
Implements #9230 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent b16025e commit 38aad67

File tree

8 files changed

+138
-4
lines changed

8 files changed

+138
-4
lines changed

packages/@aws-cdk/aws-autoscaling-hooktargets/lib/lambda-hook.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as autoscaling from '@aws-cdk/aws-autoscaling';
2+
import * as kms from '@aws-cdk/aws-kms';
23
import * as lambda from '@aws-cdk/aws-lambda';
34
import * as sns from '@aws-cdk/aws-sns';
45
import * as subs from '@aws-cdk/aws-sns-subscriptions';
@@ -11,11 +12,22 @@ import { TopicHook } from './topic-hook';
1112
* Internally creates a Topic to make the connection.
1213
*/
1314
export class FunctionHook implements autoscaling.ILifecycleHookTarget {
14-
constructor(private readonly fn: lambda.IFunction) {
15+
/**
16+
* @param fn Function to invoke in response to a lifecycle event
17+
* @param encryptionKey If provided, this key is used to encrypt the contents of the SNS topic.
18+
*/
19+
constructor(private readonly fn: lambda.IFunction, private readonly encryptionKey?: kms.IKey) {
1520
}
1621

1722
public bind(scope: Construct, lifecycleHook: autoscaling.ILifecycleHook): autoscaling.LifecycleHookTargetConfig {
18-
const topic = new sns.Topic(scope, 'Topic');
23+
const topic = new sns.Topic(scope, 'Topic', {
24+
masterKey: this.encryptionKey,
25+
});
26+
// Per: https://docs.aws.amazon.com/sns/latest/dg/sns-key-management.html#sns-what-permissions-for-sse
27+
// Topic's grantPublish() is in a base class that does not know there is a kms key, and so does not
28+
// grant appropriate permissions to the kms key. We do that here to ensure the correct permissions
29+
// are in place.
30+
this.encryptionKey?.grant(lifecycleHook.role, 'kms:Decrypt', 'kms:GenerateDataKey');
1931
topic.addSubscription(new subs.LambdaSubscription(this.fn));
2032
return new TopicHook(topic).bind(scope, lifecycleHook);
2133
}

packages/@aws-cdk/aws-autoscaling-hooktargets/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"dependencies": {
7070
"@aws-cdk/aws-autoscaling": "0.0.0",
7171
"@aws-cdk/aws-iam": "0.0.0",
72+
"@aws-cdk/aws-kms": "0.0.0",
7273
"@aws-cdk/aws-lambda": "0.0.0",
7374
"@aws-cdk/aws-sns": "0.0.0",
7475
"@aws-cdk/aws-sns-subscriptions": "0.0.0",
@@ -80,6 +81,7 @@
8081
"peerDependencies": {
8182
"@aws-cdk/aws-autoscaling": "0.0.0",
8283
"@aws-cdk/aws-iam": "0.0.0",
84+
"@aws-cdk/aws-kms": "0.0.0",
8385
"@aws-cdk/aws-lambda": "0.0.0",
8486
"@aws-cdk/aws-sns": "0.0.0",
8587
"@aws-cdk/aws-sns-subscriptions": "0.0.0",

packages/@aws-cdk/aws-autoscaling-hooktargets/test/hooks.test.ts

+46
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import '@aws-cdk/assert/jest';
2+
import { arrayWith } from '@aws-cdk/assert';
23
import * as autoscaling from '@aws-cdk/aws-autoscaling';
34
import * as ec2 from '@aws-cdk/aws-ec2';
5+
import * as kms from '@aws-cdk/aws-kms';
46
import * as lambda from '@aws-cdk/aws-lambda';
57
import * as sns from '@aws-cdk/aws-sns';
68
import * as sqs from '@aws-cdk/aws-sqs';
79
import { Stack } from '@aws-cdk/core';
810
import * as hooks from '../lib';
911

12+
1013
describe('given an AutoScalingGroup', () => {
1114
let stack: Stack;
1215
let asg: autoscaling.AutoScalingGroup;
@@ -77,4 +80,47 @@ describe('given an AutoScalingGroup', () => {
7780
Endpoint: { 'Fn::GetAtt': [ 'Fn9270CBC0', 'Arn' ] },
7881
});
7982
});
83+
84+
test('can use Lambda function as hook target with encrypted SNS', () => {
85+
// GIVEN
86+
const key = new kms.Key(stack, 'key');
87+
const fn = new lambda.Function(stack, 'Fn', {
88+
code: lambda.Code.fromInline('foo'),
89+
runtime: lambda.Runtime.NODEJS_10_X,
90+
handler: 'index.index',
91+
});
92+
93+
// WHEN
94+
asg.addLifecycleHook('Trans', {
95+
lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING,
96+
notificationTarget: new hooks.FunctionHook(fn, key),
97+
});
98+
99+
// THEN
100+
expect(stack).toHaveResourceLike('AWS::SNS::Topic', {
101+
KmsMasterKeyId: {
102+
Ref: 'keyFEDD6EC0',
103+
},
104+
});
105+
expect(stack).toHaveResourceLike('AWS::IAM::Policy', {
106+
PolicyDocument: {
107+
Statement: arrayWith(
108+
{
109+
Effect: 'Allow',
110+
Action: [
111+
'kms:Decrypt',
112+
'kms:GenerateDataKey',
113+
],
114+
Resource: {
115+
'Fn::GetAtt': [
116+
'keyFEDD6EC0',
117+
'Arn',
118+
],
119+
},
120+
},
121+
),
122+
},
123+
});
124+
});
125+
80126
});

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

+18
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,24 @@ cluster.addCapacity('AsgSpot', {
146146
});
147147
```
148148

149+
### SNS Topic Encryption
150+
151+
When the `ecs.AddCapacityOptions` that you provide has a non-zero `taskDrainTime` (the default) then an SNS topic and Lambda are created to ensure that the
152+
cluster's instances have been properly drained of tasks before terminating. The SNS Topic is sent the instance-terminating lifecycle event from the AutoScalingGroup,
153+
and the Lambda acts on that event. If you wish to engage [server-side encryption](https://docs.aws.amazon.com/sns/latest/dg/sns-data-encryption.html) for this SNS Topic
154+
then you may do so by providing a KMS key for the `topicEncryptionKey` propery of `ecs.AddCapacityOptions`.
155+
156+
```ts
157+
// Given
158+
const key = kms.Key(...);
159+
// Then, use that key to encrypt the lifecycle-event SNS Topic.
160+
cluster.addCapacity('ASGEncryptedSNS', {
161+
instanceType: new ec2.InstanceType("t2.xlarge"),
162+
desiredCapacity: 3,
163+
topicEncryptionKey: key,
164+
});
165+
```
166+
149167
## Task definitions
150168

151169
A task Definition describes what a single copy of a **task** should look like.

packages/@aws-cdk/aws-ecs/lib/cluster.ts

+12
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as autoscaling from '@aws-cdk/aws-autoscaling';
22
import * as cloudwatch from '@aws-cdk/aws-cloudwatch';
33
import * as ec2 from '@aws-cdk/aws-ec2';
44
import * as iam from '@aws-cdk/aws-iam';
5+
import * as kms from '@aws-cdk/aws-kms';
56
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
67
import * as ssm from '@aws-cdk/aws-ssm';
78
import { Construct, Duration, IResource, Resource, Stack } from '@aws-cdk/core';
@@ -259,6 +260,7 @@ export class Cluster extends Resource implements ICluster {
259260
autoScalingGroup,
260261
cluster: this,
261262
drainTime: options.taskDrainTime,
263+
topicEncryptionKey: options.topicEncryptionKey,
262264
});
263265
}
264266
}
@@ -666,6 +668,16 @@ export interface AddAutoScalingGroupCapacityOptions {
666668
* @default false
667669
*/
668670
readonly spotInstanceDraining?: boolean
671+
672+
/**
673+
* If {@link AddAutoScalingGroupCapacityOptions.taskDrainTime} is non-zero, then the ECS cluster creates an
674+
* SNS Topic to as part of a system to drain instances of tasks when the instance is being shut down.
675+
* If this property is provided, then this key will be used to encrypt the contents of that SNS Topic.
676+
* See [SNS Data Encryption](https://docs.aws.amazon.com/sns/latest/dg/sns-data-encryption.html) for more information.
677+
*
678+
* @default The SNS Topic will not be encrypted.
679+
*/
680+
readonly topicEncryptionKey?: kms.IKey;
669681
}
670682

671683
/**

packages/@aws-cdk/aws-ecs/lib/drain-hook/instance-drain-hook.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as autoscaling from '@aws-cdk/aws-autoscaling';
22
import * as hooks from '@aws-cdk/aws-autoscaling-hooktargets';
33
import * as iam from '@aws-cdk/aws-iam';
4+
import * as kms from '@aws-cdk/aws-kms';
45
import * as lambda from '@aws-cdk/aws-lambda';
56
import * as cdk from '@aws-cdk/core';
67
import * as fs from 'fs';
@@ -33,6 +34,15 @@ export interface InstanceDrainHookProps {
3334
* @default Duration.minutes(15)
3435
*/
3536
drainTime?: cdk.Duration;
37+
38+
/**
39+
* The InstanceDrainHook creates an SNS topic for the lifecycle hook of the ASG. If provided, then this
40+
* key will be used to encrypt the contents of that SNS Topic.
41+
* See [SNS Data Encryption](https://docs.aws.amazon.com/sns/latest/dg/sns-data-encryption.html) for more information.
42+
*
43+
* @default The SNS Topic will not be encrypted.
44+
*/
45+
topicEncryptionKey?: kms.IKey;
3646
}
3747

3848
/**
@@ -65,7 +75,7 @@ export class InstanceDrainHook extends cdk.Construct {
6575
props.autoScalingGroup.addLifecycleHook('DrainHook', {
6676
lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING,
6777
defaultResult: autoscaling.DefaultResult.CONTINUE,
68-
notificationTarget: new hooks.FunctionHook(fn),
78+
notificationTarget: new hooks.FunctionHook(fn, props.topicEncryptionKey),
6979
heartbeatTimeout: drainTime,
7080
});
7181

packages/@aws-cdk/aws-ecs/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"@aws-cdk/aws-elasticloadbalancing": "0.0.0",
8686
"@aws-cdk/aws-elasticloadbalancingv2": "0.0.0",
8787
"@aws-cdk/aws-iam": "0.0.0",
88+
"@aws-cdk/aws-kms": "0.0.0",
8889
"@aws-cdk/aws-lambda": "0.0.0",
8990
"@aws-cdk/aws-logs": "0.0.0",
9091
"@aws-cdk/aws-route53": "0.0.0",
@@ -111,6 +112,7 @@
111112
"@aws-cdk/aws-elasticloadbalancing": "0.0.0",
112113
"@aws-cdk/aws-elasticloadbalancingv2": "0.0.0",
113114
"@aws-cdk/aws-iam": "0.0.0",
115+
"@aws-cdk/aws-kms": "0.0.0",
114116
"@aws-cdk/aws-lambda": "0.0.0",
115117
"@aws-cdk/aws-logs": "0.0.0",
116118
"@aws-cdk/aws-route53": "0.0.0",

packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
1-
import { countResources, expect, haveResource, ResourcePart } from '@aws-cdk/assert';
1+
import {
2+
countResources,
3+
expect,
4+
haveResource,
5+
haveResourceLike,
6+
ResourcePart,
7+
} from '@aws-cdk/assert';
28
import * as ec2 from '@aws-cdk/aws-ec2';
9+
import * as kms from '@aws-cdk/aws-kms';
310
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
411
import * as cdk from '@aws-cdk/core';
512
import { Test } from 'nodeunit';
@@ -501,6 +508,31 @@ export = {
501508
test.done();
502509
},
503510

511+
'lifecycle hook with encrypted SNS is added correctly'(test: Test) {
512+
// GIVEN
513+
const stack = new cdk.Stack();
514+
const vpc = new ec2.Vpc(stack, 'MyVpc', {});
515+
const cluster = new ecs.Cluster(stack, 'EcsCluster', {
516+
vpc,
517+
});
518+
const key = new kms.Key(stack, 'Key');
519+
520+
// WHEN
521+
cluster.addCapacity('DefaultAutoScalingGroup', {
522+
instanceType: new ec2.InstanceType('t2.micro'),
523+
topicEncryptionKey: key,
524+
});
525+
526+
// THEN
527+
expect(stack).to(haveResourceLike('AWS::SNS::Topic', {
528+
KmsMasterKeyId: {
529+
Ref: 'Key961B73FD',
530+
},
531+
}));
532+
533+
test.done();
534+
},
535+
504536
'with capacity and cloudmap namespace properties set'(test: Test) {
505537
// GIVEN
506538
const stack = new cdk.Stack();

0 commit comments

Comments
 (0)