Skip to content

Commit

Permalink
feat(ecs): Option to encrypt lifecycle hook SNS Topic (#9343)
Browse files Browse the repository at this point in the history
Implements #9230

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
ddneilson authored Aug 11, 2020
1 parent b16025e commit 38aad67
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 4 deletions.
16 changes: 14 additions & 2 deletions packages/@aws-cdk/aws-autoscaling-hooktargets/lib/lambda-hook.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as autoscaling from '@aws-cdk/aws-autoscaling';
import * as kms from '@aws-cdk/aws-kms';
import * as lambda from '@aws-cdk/aws-lambda';
import * as sns from '@aws-cdk/aws-sns';
import * as subs from '@aws-cdk/aws-sns-subscriptions';
Expand All @@ -11,11 +12,22 @@ import { TopicHook } from './topic-hook';
* Internally creates a Topic to make the connection.
*/
export class FunctionHook implements autoscaling.ILifecycleHookTarget {
constructor(private readonly fn: lambda.IFunction) {
/**
* @param fn Function to invoke in response to a lifecycle event
* @param encryptionKey If provided, this key is used to encrypt the contents of the SNS topic.
*/
constructor(private readonly fn: lambda.IFunction, private readonly encryptionKey?: kms.IKey) {
}

public bind(scope: Construct, lifecycleHook: autoscaling.ILifecycleHook): autoscaling.LifecycleHookTargetConfig {
const topic = new sns.Topic(scope, 'Topic');
const topic = new sns.Topic(scope, 'Topic', {
masterKey: this.encryptionKey,
});
// Per: https://docs.aws.amazon.com/sns/latest/dg/sns-key-management.html#sns-what-permissions-for-sse
// Topic's grantPublish() is in a base class that does not know there is a kms key, and so does not
// grant appropriate permissions to the kms key. We do that here to ensure the correct permissions
// are in place.
this.encryptionKey?.grant(lifecycleHook.role, 'kms:Decrypt', 'kms:GenerateDataKey');
topic.addSubscription(new subs.LambdaSubscription(this.fn));
return new TopicHook(topic).bind(scope, lifecycleHook);
}
Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-autoscaling-hooktargets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"dependencies": {
"@aws-cdk/aws-autoscaling": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-kms": "0.0.0",
"@aws-cdk/aws-lambda": "0.0.0",
"@aws-cdk/aws-sns": "0.0.0",
"@aws-cdk/aws-sns-subscriptions": "0.0.0",
Expand All @@ -80,6 +81,7 @@
"peerDependencies": {
"@aws-cdk/aws-autoscaling": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-kms": "0.0.0",
"@aws-cdk/aws-lambda": "0.0.0",
"@aws-cdk/aws-sns": "0.0.0",
"@aws-cdk/aws-sns-subscriptions": "0.0.0",
Expand Down
46 changes: 46 additions & 0 deletions packages/@aws-cdk/aws-autoscaling-hooktargets/test/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import '@aws-cdk/assert/jest';
import { arrayWith } from '@aws-cdk/assert';
import * as autoscaling from '@aws-cdk/aws-autoscaling';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as kms from '@aws-cdk/aws-kms';
import * as lambda from '@aws-cdk/aws-lambda';
import * as sns from '@aws-cdk/aws-sns';
import * as sqs from '@aws-cdk/aws-sqs';
import { Stack } from '@aws-cdk/core';
import * as hooks from '../lib';


describe('given an AutoScalingGroup', () => {
let stack: Stack;
let asg: autoscaling.AutoScalingGroup;
Expand Down Expand Up @@ -77,4 +80,47 @@ describe('given an AutoScalingGroup', () => {
Endpoint: { 'Fn::GetAtt': [ 'Fn9270CBC0', 'Arn' ] },
});
});

test('can use Lambda function as hook target with encrypted SNS', () => {
// GIVEN
const key = new kms.Key(stack, 'key');
const fn = new lambda.Function(stack, 'Fn', {
code: lambda.Code.fromInline('foo'),
runtime: lambda.Runtime.NODEJS_10_X,
handler: 'index.index',
});

// WHEN
asg.addLifecycleHook('Trans', {
lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING,
notificationTarget: new hooks.FunctionHook(fn, key),
});

// THEN
expect(stack).toHaveResourceLike('AWS::SNS::Topic', {
KmsMasterKeyId: {
Ref: 'keyFEDD6EC0',
},
});
expect(stack).toHaveResourceLike('AWS::IAM::Policy', {
PolicyDocument: {
Statement: arrayWith(
{
Effect: 'Allow',
Action: [
'kms:Decrypt',
'kms:GenerateDataKey',
],
Resource: {
'Fn::GetAtt': [
'keyFEDD6EC0',
'Arn',
],
},
},
),
},
});
});

});
18 changes: 18 additions & 0 deletions packages/@aws-cdk/aws-ecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@ cluster.addCapacity('AsgSpot', {
});
```

### SNS Topic Encryption

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
cluster's instances have been properly drained of tasks before terminating. The SNS Topic is sent the instance-terminating lifecycle event from the AutoScalingGroup,
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
then you may do so by providing a KMS key for the `topicEncryptionKey` propery of `ecs.AddCapacityOptions`.

```ts
// Given
const key = kms.Key(...);
// Then, use that key to encrypt the lifecycle-event SNS Topic.
cluster.addCapacity('ASGEncryptedSNS', {
instanceType: new ec2.InstanceType("t2.xlarge"),
desiredCapacity: 3,
topicEncryptionKey: key,
});
```

## Task definitions

A task Definition describes what a single copy of a **task** should look like.
Expand Down
12 changes: 12 additions & 0 deletions packages/@aws-cdk/aws-ecs/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as autoscaling from '@aws-cdk/aws-autoscaling';
import * as cloudwatch from '@aws-cdk/aws-cloudwatch';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
import * as ssm from '@aws-cdk/aws-ssm';
import { Construct, Duration, IResource, Resource, Stack } from '@aws-cdk/core';
Expand Down Expand Up @@ -259,6 +260,7 @@ export class Cluster extends Resource implements ICluster {
autoScalingGroup,
cluster: this,
drainTime: options.taskDrainTime,
topicEncryptionKey: options.topicEncryptionKey,
});
}
}
Expand Down Expand Up @@ -666,6 +668,16 @@ export interface AddAutoScalingGroupCapacityOptions {
* @default false
*/
readonly spotInstanceDraining?: boolean

/**
* If {@link AddAutoScalingGroupCapacityOptions.taskDrainTime} is non-zero, then the ECS cluster creates an
* SNS Topic to as part of a system to drain instances of tasks when the instance is being shut down.
* If this property is provided, then this key will be used to encrypt the contents of that SNS Topic.
* See [SNS Data Encryption](https://docs.aws.amazon.com/sns/latest/dg/sns-data-encryption.html) for more information.
*
* @default The SNS Topic will not be encrypted.
*/
readonly topicEncryptionKey?: kms.IKey;
}

/**
Expand Down
12 changes: 11 additions & 1 deletion packages/@aws-cdk/aws-ecs/lib/drain-hook/instance-drain-hook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as autoscaling from '@aws-cdk/aws-autoscaling';
import * as hooks from '@aws-cdk/aws-autoscaling-hooktargets';
import * as iam from '@aws-cdk/aws-iam';
import * as kms from '@aws-cdk/aws-kms';
import * as lambda from '@aws-cdk/aws-lambda';
import * as cdk from '@aws-cdk/core';
import * as fs from 'fs';
Expand Down Expand Up @@ -33,6 +34,15 @@ export interface InstanceDrainHookProps {
* @default Duration.minutes(15)
*/
drainTime?: cdk.Duration;

/**
* The InstanceDrainHook creates an SNS topic for the lifecycle hook of the ASG. If provided, then this
* key will be used to encrypt the contents of that SNS Topic.
* See [SNS Data Encryption](https://docs.aws.amazon.com/sns/latest/dg/sns-data-encryption.html) for more information.
*
* @default The SNS Topic will not be encrypted.
*/
topicEncryptionKey?: kms.IKey;
}

/**
Expand Down Expand Up @@ -65,7 +75,7 @@ export class InstanceDrainHook extends cdk.Construct {
props.autoScalingGroup.addLifecycleHook('DrainHook', {
lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING,
defaultResult: autoscaling.DefaultResult.CONTINUE,
notificationTarget: new hooks.FunctionHook(fn),
notificationTarget: new hooks.FunctionHook(fn, props.topicEncryptionKey),
heartbeatTimeout: drainTime,
});

Expand Down
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-ecs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"@aws-cdk/aws-elasticloadbalancing": "0.0.0",
"@aws-cdk/aws-elasticloadbalancingv2": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-kms": "0.0.0",
"@aws-cdk/aws-lambda": "0.0.0",
"@aws-cdk/aws-logs": "0.0.0",
"@aws-cdk/aws-route53": "0.0.0",
Expand All @@ -111,6 +112,7 @@
"@aws-cdk/aws-elasticloadbalancing": "0.0.0",
"@aws-cdk/aws-elasticloadbalancingv2": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/aws-kms": "0.0.0",
"@aws-cdk/aws-lambda": "0.0.0",
"@aws-cdk/aws-logs": "0.0.0",
"@aws-cdk/aws-route53": "0.0.0",
Expand Down
34 changes: 33 additions & 1 deletion packages/@aws-cdk/aws-ecs/test/test.ecs-cluster.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { countResources, expect, haveResource, ResourcePart } from '@aws-cdk/assert';
import {
countResources,
expect,
haveResource,
haveResourceLike,
ResourcePart,
} from '@aws-cdk/assert';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as kms from '@aws-cdk/aws-kms';
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
import * as cdk from '@aws-cdk/core';
import { Test } from 'nodeunit';
Expand Down Expand Up @@ -501,6 +508,31 @@ export = {
test.done();
},

'lifecycle hook with encrypted SNS is added correctly'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.Vpc(stack, 'MyVpc', {});
const cluster = new ecs.Cluster(stack, 'EcsCluster', {
vpc,
});
const key = new kms.Key(stack, 'Key');

// WHEN
cluster.addCapacity('DefaultAutoScalingGroup', {
instanceType: new ec2.InstanceType('t2.micro'),
topicEncryptionKey: key,
});

// THEN
expect(stack).to(haveResourceLike('AWS::SNS::Topic', {
KmsMasterKeyId: {
Ref: 'Key961B73FD',
},
}));

test.done();
},

'with capacity and cloudmap namespace properties set'(test: Test) {
// GIVEN
const stack = new cdk.Stack();
Expand Down

0 comments on commit 38aad67

Please sign in to comment.