From 4e7a2758eec6999aee5432b3e9e6bbe7626a2d6b Mon Sep 17 00:00:00 2001 From: Calvin Combs <66279577+comcalvi@users.noreply.github.com> Date: Fri, 10 Dec 2021 16:58:29 -0800 Subject: [PATCH] fix(aws-autoscaling): notificationTargetArn should be optional in LifecycleHook (#16187) This makes the notificationTargetArn optional in LifecycleHook. CloudFormation docs specify it as optional [here](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-as-lifecyclehook.html). Closes #14641. To achieve this, the `role` parameter was made optional. To avoid breaking users, a role is provided if users specify a `notificationTarget` (which they currently all do, as it is a required property) and is not provided otherwise. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- allowed-breaking-changes.txt | 7 + .../aws-autoscaling-hooktargets/lib/common.ts | 17 + .../aws-autoscaling-hooktargets/lib/index.ts | 1 + .../lib/lambda-hook.ts | 18 +- .../lib/queue-hook.ts | 23 +- .../lib/topic-hook.ts | 23 +- .../test/hooks.test.ts | 233 +++++- .../lib/lifecycle-hook-target.ts | 42 +- .../aws-autoscaling/lib/lifecycle-hook.ts | 47 +- .../test/integ.amazonlinux2.expected.json | 12 +- ...g.asg-w-classic-loadbalancer.expected.json | 18 +- .../test/integ.custom-scaling.expected.json | 12 +- .../test/integ.external-role.expected.json | 18 +- .../test/integ.role-target-hook.expected.json | 788 ++++++++++++++++++ .../test/integ.role-target-hook.ts | 79 ++ .../test/lifecyclehooks.test.ts | 163 +++- 16 files changed, 1411 insertions(+), 90 deletions(-) create mode 100644 packages/@aws-cdk/aws-autoscaling-hooktargets/lib/common.ts create mode 100644 packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.expected.json create mode 100644 packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.ts diff --git a/allowed-breaking-changes.txt b/allowed-breaking-changes.txt index 6b5d57a000a4e..fa3498335f679 100644 --- a/allowed-breaking-changes.txt +++ b/allowed-breaking-changes.txt @@ -86,3 +86,10 @@ removed:@aws-cdk/aws-stepfunctions-tasks.EmrCreateClusterProps.autoTerminationPo # Changed property securityGroupId to optional because either securityGroupId or # securityGroupName is required. Therefore securityGroupId is no longer mandatory. weakened:@aws-cdk/cloud-assembly-schema.SecurityGroupContextQuery + +# refactor autoscaling lifecycle hook target bind() methods to make role optional by +# having bind() methods create the role if it isn't passed to them +incompatible-argument:@aws-cdk/aws-autoscaling-hooktargets.FunctionHook.bind +incompatible-argument:@aws-cdk/aws-autoscaling-hooktargets.QueueHook.bind +incompatible-argument:@aws-cdk/aws-autoscaling-hooktargets.TopicHook.bind +incompatible-argument:@aws-cdk/aws-autoscaling.ILifecycleHookTarget.bind diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/common.ts b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/common.ts new file mode 100644 index 0000000000000..e16530d6231ff --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/common.ts @@ -0,0 +1,17 @@ +// eslint-disable-next-line import/order +import * as iam from '@aws-cdk/aws-iam'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import * as constructs from 'constructs'; + +export function createRole(scope: constructs.Construct, _role?: iam.IRole) { + let role = _role; + if (!role) { + role = new iam.Role(scope, 'Role', { + assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'), + }); + } + + return role; +} diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/index.ts b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/index.ts index 4b48b2ab6d1b7..53591e6610bd8 100644 --- a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/index.ts +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/index.ts @@ -1,3 +1,4 @@ +export * from './common'; export * from './queue-hook'; export * from './topic-hook'; export * from './lambda-hook'; diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/lambda-hook.ts b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/lambda-hook.ts index dbe170438320e..766b4d0149a8a 100644 --- a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/lambda-hook.ts +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/lambda-hook.ts @@ -4,11 +4,12 @@ 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'; +import { createRole } from './common'; import { TopicHook } from './topic-hook'; // keep this import separate from other imports to reduce chance for merge conflicts with v2-main // eslint-disable-next-line no-duplicate-imports, import/order -import { Construct } from '@aws-cdk/core'; +import { Construct } from 'constructs'; /** * Use a Lambda Function as a hook target @@ -23,16 +24,23 @@ export class FunctionHook implements autoscaling.ILifecycleHookTarget { 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', { + /** + * If the `IRole` does not exist in `options`, will create an `IRole` and an SNS Topic and attach both to the lifecycle hook. + * If the `IRole` does exist in `options`, will only create an SNS Topic and attach it to the lifecycle hook. + */ + public bind(_scope: Construct, options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig { + const topic = new sns.Topic(_scope, 'Topic', { masterKey: this.encryptionKey, }); + + const role = createRole(_scope, options.role); + // 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'); + this.encryptionKey?.grant(role, 'kms:Decrypt', 'kms:GenerateDataKey'); topic.addSubscription(new subs.LambdaSubscription(this.fn)); - return new TopicHook(topic).bind(scope, lifecycleHook); + return new TopicHook(topic).bind(_scope, { lifecycleHook: options.lifecycleHook, role }); } } diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/queue-hook.ts b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/queue-hook.ts index 640cd2a8b0ac6..621c5d3be49af 100644 --- a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/queue-hook.ts +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/queue-hook.ts @@ -1,6 +1,10 @@ import * as autoscaling from '@aws-cdk/aws-autoscaling'; import * as sqs from '@aws-cdk/aws-sqs'; -import { Construct } from '@aws-cdk/core'; +import { createRole } from './common'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from 'constructs'; /** * Use an SQS queue as a hook target @@ -9,8 +13,19 @@ export class QueueHook implements autoscaling.ILifecycleHookTarget { constructor(private readonly queue: sqs.IQueue) { } - public bind(_scope: Construct, lifecycleHook: autoscaling.ILifecycleHook): autoscaling.LifecycleHookTargetConfig { - this.queue.grantSendMessages(lifecycleHook.role); - return { notificationTargetArn: this.queue.queueArn }; + /** + * If an `IRole` is found in `options`, grant it access to send messages. + * Otherwise, create a new `IRole` and grant it access to send messages. + * + * @returns the `IRole` with access to send messages and the ARN of the queue it has access to send messages to. + */ + public bind(_scope: Construct, options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig { + const role = createRole(_scope, options.role); + this.queue.grantSendMessages(role); + + return { + notificationTargetArn: this.queue.queueArn, + createdRole: role, + }; } } diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/topic-hook.ts b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/topic-hook.ts index 1f6546ebd75ac..168b88bba61c7 100644 --- a/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/topic-hook.ts +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/lib/topic-hook.ts @@ -1,6 +1,10 @@ import * as autoscaling from '@aws-cdk/aws-autoscaling'; import * as sns from '@aws-cdk/aws-sns'; -import { Construct } from '@aws-cdk/core'; +import { createRole } from './common'; + +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from 'constructs'; /** * Use an SNS topic as a hook target @@ -9,8 +13,19 @@ export class TopicHook implements autoscaling.ILifecycleHookTarget { constructor(private readonly topic: sns.ITopic) { } - public bind(_scope: Construct, lifecycleHook: autoscaling.ILifecycleHook): autoscaling.LifecycleHookTargetConfig { - this.topic.grantPublish(lifecycleHook.role); - return { notificationTargetArn: this.topic.topicArn }; + /** + * If an `IRole` is found in `options`, grant it topic publishing permissions. + * Otherwise, create a new `IRole` and grant it topic publishing permissions. + * + * @returns the `IRole` with topic publishing permissions and the ARN of the topic it has publishing permission to. + */ + public bind(_scope: Construct, options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig { + const role = createRole(_scope, options.role); + this.topic.grantPublish(role); + + return { + notificationTargetArn: this.topic.topicArn, + createdRole: role, + }; } } diff --git a/packages/@aws-cdk/aws-autoscaling-hooktargets/test/hooks.test.ts b/packages/@aws-cdk/aws-autoscaling-hooktargets/test/hooks.test.ts index d984693280351..4615c45913b44 100644 --- a/packages/@aws-cdk/aws-autoscaling-hooktargets/test/hooks.test.ts +++ b/packages/@aws-cdk/aws-autoscaling-hooktargets/test/hooks.test.ts @@ -2,6 +2,7 @@ import '@aws-cdk/assert-internal/jest'; import { arrayWith } from '@aws-cdk/assert-internal'; import * as autoscaling from '@aws-cdk/aws-autoscaling'; 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 lambda from '@aws-cdk/aws-lambda'; import * as sns from '@aws-cdk/aws-sns'; @@ -10,7 +11,7 @@ import { Stack } from '@aws-cdk/core'; import * as hooks from '../lib'; -describe('given an AutoScalingGroup', () => { +describe('given an AutoScalingGroup and no role', () => { let stack: Stack; let asg: autoscaling.AutoScalingGroup; @@ -25,7 +26,24 @@ describe('given an AutoScalingGroup', () => { }); }); - test('can use queue as hook target', () => { + afterEach(() => { + expect(stack).toHaveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'autoscaling.amazonaws.com', + }, + }, + ], + }, + }); + }); + + test('can use queue as hook target without providing a role', () => { // GIVEN const queue = new sqs.Queue(stack, 'Queue'); @@ -37,9 +55,36 @@ describe('given an AutoScalingGroup', () => { // THEN expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { NotificationTargetARN: { 'Fn::GetAtt': ['Queue4A7E3555', 'Arn'] } }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: [ + 'sqs:SendMessage', + 'sqs:GetQueueAttributes', + 'sqs:GetQueueUrl', + ], + Effect: 'Allow', + Resource: { + 'Fn::GetAtt': [ + 'Queue4A7E3555', + 'Arn', + ], + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'ASGLifecycleHookTransRoleDefaultPolicy43D7C82A', + Roles: [ + { + Ref: 'ASGLifecycleHookTransRole71E0A219', + }, + ], + }); }); - test('can use topic as hook target', () => { + test('can use topic as hook target without providing a role', () => { // GIVEN const topic = new sns.Topic(stack, 'Topic'); @@ -50,12 +95,30 @@ describe('given an AutoScalingGroup', () => { }); // THEN - expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { - NotificationTargetARN: { Ref: 'TopicBFC7AF6E' }, + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { NotificationTargetARN: { Ref: 'TopicBFC7AF6E' } }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'sns:Publish', + Effect: 'Allow', + Resource: { + Ref: 'TopicBFC7AF6E', + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'ASGLifecycleHookTransRoleDefaultPolicy43D7C82A', + Roles: [ + { + Ref: 'ASGLifecycleHookTransRole71E0A219', + }, + ], }); }); - test('can use Lambda function as hook target', () => { + test('can use Lambda function as hook target without providing a role', () => { // GIVEN const fn = new lambda.Function(stack, 'Fn', { code: lambda.Code.fromInline('foo'), @@ -70,14 +133,32 @@ describe('given an AutoScalingGroup', () => { }); // THEN - expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { - NotificationTargetARN: { Ref: 'ASGLifecycleHookTransTopic9B0D4842' }, - }); + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { NotificationTargetARN: { Ref: 'ASGLifecycleHookTransTopic9B0D4842' } }); expect(stack).toHaveResource('AWS::SNS::Subscription', { Protocol: 'lambda', TopicArn: { Ref: 'ASGLifecycleHookTransTopic9B0D4842' }, Endpoint: { 'Fn::GetAtt': ['Fn9270CBC0', 'Arn'] }, }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'sns:Publish', + Effect: 'Allow', + Resource: { + Ref: 'ASGLifecycleHookTransTopic9B0D4842', + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'ASGLifecycleHookTransRoleDefaultPolicy43D7C82A', + Roles: [ + { + Ref: 'ASGLifecycleHookTransRole71E0A219', + }, + ], + }); }); test('can use Lambda function as hook target with encrypted SNS', () => { @@ -124,5 +205,139 @@ describe('given an AutoScalingGroup', () => { }, }); }); +}); + +describe('given an AutoScalingGroup and a role', () => { + let stack: Stack; + let asg: autoscaling.AutoScalingGroup; + + beforeEach(() => { + stack = new Stack(); + + const vpc = new ec2.Vpc(stack, 'VPC'); + asg = new autoscaling.AutoScalingGroup(stack, 'ASG', { + vpc, + instanceType: new ec2.InstanceType('t2.micro'), + machineImage: new ec2.AmazonLinuxImage(), + }); + }); + + afterEach(() => { + expect(stack).toHaveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'custom.role.domain.com', + }, + }, + ], + }, + }); + }); + test('can use queue as hook target with a role', () => { + // GIVEN + const queue = new sqs.Queue(stack, 'Queue'); + const myrole = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('custom.role.domain.com'), + }); + // WHEN + asg.addLifecycleHook('Trans', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING, + notificationTarget: new hooks.QueueHook(queue), + role: myrole, + }); + + // THEN + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { NotificationTargetARN: { 'Fn::GetAtt': ['Queue4A7E3555', 'Arn'] } }); + }); + + test('can use topic as hook target with a role', () => { + // GIVEN + const topic = new sns.Topic(stack, 'Topic'); + const myrole = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('custom.role.domain.com'), + }); + + // WHEN + asg.addLifecycleHook('Trans', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING, + notificationTarget: new hooks.TopicHook(topic), + role: myrole, + }); + + // THEN + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { NotificationTargetARN: { Ref: 'TopicBFC7AF6E' } }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'sns:Publish', + Effect: 'Allow', + Resource: { + Ref: 'TopicBFC7AF6E', + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'MyRoleDefaultPolicyA36BE1DD', + Roles: [ + { + Ref: 'MyRoleF48FFE04', + }, + ], + }); + }); + + test('can use Lambda function as hook target with a role', () => { + // GIVEN + const fn = new lambda.Function(stack, 'Fn', { + code: lambda.Code.fromInline('foo'), + runtime: lambda.Runtime.NODEJS_10_X, + handler: 'index.index', + }); + const myrole = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('custom.role.domain.com'), + }); + + // WHEN + asg.addLifecycleHook('Trans', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING, + notificationTarget: new hooks.FunctionHook(fn), + role: myrole, + }); + + // THEN + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { NotificationTargetARN: { Ref: 'ASGLifecycleHookTransTopic9B0D4842' } }); + expect(stack).toHaveResource('AWS::SNS::Subscription', { + Protocol: 'lambda', + TopicArn: { Ref: 'ASGLifecycleHookTransTopic9B0D4842' }, + Endpoint: { 'Fn::GetAtt': ['Fn9270CBC0', 'Arn'] }, + }); + expect(stack).toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'sns:Publish', + Effect: 'Allow', + Resource: { + Ref: 'ASGLifecycleHookTransTopic9B0D4842', + }, + }, + ], + Version: '2012-10-17', + }, + PolicyName: 'MyRoleDefaultPolicyA36BE1DD', + Roles: [ + { + Ref: 'MyRoleF48FFE04', + }, + ], + }); + }); }); diff --git a/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook-target.ts b/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook-target.ts index e15ae3ef081b5..4004b5de39f67 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook-target.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook-target.ts @@ -1,26 +1,50 @@ - -import { ILifecycleHook } from './lifecycle-hook'; +// eslint-disable-next-line import/order +import { LifecycleHook } from './lifecycle-hook'; +import * as iam from '@aws-cdk/aws-iam'; // keep this import separate from other imports to reduce chance for merge conflicts with v2-main // eslint-disable-next-line no-duplicate-imports, import/order -import { Construct } from '@aws-cdk/core'; +import * as constructs from 'constructs'; /** - * Interface for autoscaling lifecycle hook targets + * Options needed to bind a target to a lifecycle hook. + * [disable-awslint:ref-via-interface] The lifecycle hook to attach to and an IRole to use */ -export interface ILifecycleHookTarget { +export interface BindHookTargetOptions { /** - * Called when this object is used as the target of a lifecycle hook + * The lifecycle hook to attach to. + * [disable-awslint:ref-via-interface] */ - bind(scope: Construct, lifecycleHook: ILifecycleHook): LifecycleHookTargetConfig; + readonly lifecycleHook: LifecycleHook; + /** + * The role to use when attaching to the lifecycle hook. + * [disable-awslint:ref-via-interface] + * @default: a role is not created unless the target arn is specified + */ + readonly role?: iam.IRole; } /** - * Properties to add the target to a lifecycle hook + * Result of binding a lifecycle hook to a target. */ export interface LifecycleHookTargetConfig { /** - * The ARN to use as the notification target + * The IRole that was used to bind the lifecycle hook to the target + */ + readonly createdRole: iam.IRole; + /** + * The targetArn that the lifecycle hook was bound to */ readonly notificationTargetArn: string; } + +/** + * Interface for autoscaling lifecycle hook targets + */ +export interface ILifecycleHookTarget { + /** + * Called when this object is used as the target of a lifecycle hook + * @param options [disable-awslint:ref-via-interface] The lifecycle hook to attach to and a role to use + */ + bind(scope: constructs.Construct, options: BindHookTargetOptions): LifecycleHookTargetConfig; +} diff --git a/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook.ts b/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook.ts index 4e4e8408ad326..cdcd9870ab37d 100644 --- a/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook.ts +++ b/packages/@aws-cdk/aws-autoscaling/lib/lifecycle-hook.ts @@ -46,13 +46,15 @@ export interface BasicLifecycleHookProps { /** * The target of the lifecycle hook + * + * @default - No target. */ - readonly notificationTarget: ILifecycleHookTarget; + readonly notificationTarget?: ILifecycleHookTarget; /** * The role that allows publishing to the notification target * - * @default - A role is automatically created. + * @default - A role will be created if a target is provided. Otherwise, no role is created. */ readonly role?: iam.IRole; } @@ -73,6 +75,9 @@ export interface LifecycleHookProps extends BasicLifecycleHookProps { export interface ILifecycleHook extends IResource { /** * The role for the lifecycle hook to execute + * + * @default - A default role is created if 'notificationTarget' is specified. + * Otherwise, no role is created. */ readonly role: iam.IRole; } @@ -81,10 +86,21 @@ export interface ILifecycleHook extends IResource { * Define a life cycle hook */ export class LifecycleHook extends Resource implements ILifecycleHook { + private _role?: iam.IRole; + /** * The role that allows the ASG to publish to the notification target + * + * @default - A default role is created if 'notificationTarget' is specified. + * Otherwise, no role is created. */ - public readonly role: iam.IRole; + public get role() { + if (!this._role) { + throw new Error('\'role\' is undefined. Please specify a \'role\' or specify a \'notificationTarget\' to have a role provided for you.'); + } + + return this._role; + } /** * The name of this lifecycle hook @@ -97,11 +113,20 @@ export class LifecycleHook extends Resource implements ILifecycleHook { physicalName: props.lifecycleHookName, }); - this.role = props.role || new iam.Role(this, 'Role', { - assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'), - }); + const targetProps = props.notificationTarget ? props.notificationTarget.bind(this, { lifecycleHook: this, role: props.role }) : undefined; + + if (props.role) { + this._role = props.role; + + if (!props.notificationTarget) { + throw new Error("'notificationTarget' parameter required when 'role' parameter is specified"); + } + } else { + this._role = targetProps ? targetProps.createdRole : undefined; + } - const targetProps = props.notificationTarget.bind(this, this); + const l1NotificationTargetArn = targetProps ? targetProps.notificationTargetArn : undefined; + const l1RoleArn = this._role ? this.role.roleArn : undefined; const resource = new CfnLifecycleHook(this, 'Resource', { autoScalingGroupName: props.autoScalingGroup.autoScalingGroupName, @@ -110,14 +135,16 @@ export class LifecycleHook extends Resource implements ILifecycleHook { lifecycleHookName: this.physicalName, lifecycleTransition: props.lifecycleTransition, notificationMetadata: props.notificationMetadata, - notificationTargetArn: targetProps.notificationTargetArn, - roleArn: this.role.roleArn, + notificationTargetArn: l1NotificationTargetArn, + roleArn: l1RoleArn, }); // A LifecycleHook resource is going to do a permissions test upon creation, // so we have to make sure the role has full permissions before creating the // lifecycle hook. - resource.node.addDependency(this.role); + if (this._role) { + resource.node.addDependency(this.role); + } this.lifecycleHookName = resource.ref; } diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.amazonlinux2.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.amazonlinux2.expected.json index af9c16803e320..f8514c807ed91 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/integ.amazonlinux2.expected.json +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.amazonlinux2.expected.json @@ -95,15 +95,15 @@ "VPCPublicSubnet1NATGatewayE0556630": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet1EIP6AD938E8", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet1SubnetB4246D30" - }, "Tags": [ { "Key": "Name", @@ -192,15 +192,15 @@ "VPCPublicSubnet2NATGateway3C070193": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet2EIP4947BC00", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet2Subnet74179F39" - }, "Tags": [ { "Key": "Name", diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-classic-loadbalancer.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-classic-loadbalancer.expected.json index 5882016a8f8b2..185201fe1f69e 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-classic-loadbalancer.expected.json +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.asg-w-classic-loadbalancer.expected.json @@ -95,15 +95,15 @@ "VPCPublicSubnet1NATGatewayE0556630": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet1EIP6AD938E8", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet1SubnetB4246D30" - }, "Tags": [ { "Key": "Name", @@ -192,15 +192,15 @@ "VPCPublicSubnet2NATGateway3C070193": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet2EIP4947BC00", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet2Subnet74179F39" - }, "Tags": [ { "Key": "Name", @@ -289,15 +289,15 @@ "VPCPublicSubnet3NATGatewayD3048F5C": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet3EIPAD4BC883", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet3Subnet631C5E25" - }, "Tags": [ { "Key": "Name", diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.custom-scaling.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.custom-scaling.expected.json index 21457d1ea78e6..304e554bf120d 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/integ.custom-scaling.expected.json +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.custom-scaling.expected.json @@ -95,15 +95,15 @@ "VPCPublicSubnet1NATGatewayE0556630": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet1EIP6AD938E8", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet1SubnetB4246D30" - }, "Tags": [ { "Key": "Name", @@ -192,15 +192,15 @@ "VPCPublicSubnet2NATGateway3C070193": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet2EIP4947BC00", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet2Subnet74179F39" - }, "Tags": [ { "Key": "Name", diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.external-role.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.external-role.expected.json index 49196dcc8ba93..9a01de34d1d55 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/integ.external-role.expected.json +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.external-role.expected.json @@ -95,15 +95,15 @@ "VPCPublicSubnet1NATGatewayE0556630": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet1EIP6AD938E8", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet1SubnetB4246D30" - }, "Tags": [ { "Key": "Name", @@ -192,15 +192,15 @@ "VPCPublicSubnet2NATGateway3C070193": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet2EIP4947BC00", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet2Subnet74179F39" - }, "Tags": [ { "Key": "Name", @@ -289,15 +289,15 @@ "VPCPublicSubnet3NATGatewayD3048F5C": { "Type": "AWS::EC2::NatGateway", "Properties": { + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, "AllocationId": { "Fn::GetAtt": [ "VPCPublicSubnet3EIPAD4BC883", "AllocationId" ] }, - "SubnetId": { - "Ref": "VPCPublicSubnet3Subnet631C5E25" - }, "Tags": [ { "Key": "Name", diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.expected.json b/packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.expected.json new file mode 100644 index 0000000000000..54ccaf7b2e51a --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.expected.json @@ -0,0 +1,788 @@ +{ + "Resources": { + "myVpcAuto1A4B61E2": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto" + } + ] + } + }, + "myVpcAutoPublicSubnet1Subnet3516098F": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet1" + } + ] + } + }, + "myVpcAutoPublicSubnet1RouteTable3D618310": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet1" + } + ] + } + }, + "myVpcAutoPublicSubnet1RouteTableAssociationB3A6EFAC": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPublicSubnet1RouteTable3D618310" + }, + "SubnetId": { + "Ref": "myVpcAutoPublicSubnet1Subnet3516098F" + } + } + }, + "myVpcAutoPublicSubnet1DefaultRoute2791173D": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPublicSubnet1RouteTable3D618310" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "myVpcAutoIGW08055396" + } + }, + "DependsOn": [ + "myVpcAutoVPCGWEC42CD12" + ] + }, + "myVpcAutoPublicSubnet1EIP15D99CAF": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet1" + } + ] + } + }, + "myVpcAutoPublicSubnet1NATGatewayF3EA78A2": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "myVpcAutoPublicSubnet1Subnet3516098F" + }, + "AllocationId": { + "Fn::GetAtt": [ + "myVpcAutoPublicSubnet1EIP15D99CAF", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet1" + } + ] + } + }, + "myVpcAutoPublicSubnet2Subnet297C7839": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet2" + } + ] + } + }, + "myVpcAutoPublicSubnet2RouteTable17ECF2AC": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet2" + } + ] + } + }, + "myVpcAutoPublicSubnet2RouteTableAssociationE21B7B6C": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPublicSubnet2RouteTable17ECF2AC" + }, + "SubnetId": { + "Ref": "myVpcAutoPublicSubnet2Subnet297C7839" + } + } + }, + "myVpcAutoPublicSubnet2DefaultRouteE9454F16": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPublicSubnet2RouteTable17ECF2AC" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "myVpcAutoIGW08055396" + } + }, + "DependsOn": [ + "myVpcAutoVPCGWEC42CD12" + ] + }, + "myVpcAutoPublicSubnet2EIPA484FACE": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet2" + } + ] + } + }, + "myVpcAutoPublicSubnet2NATGatewayF670624F": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "myVpcAutoPublicSubnet2Subnet297C7839" + }, + "AllocationId": { + "Fn::GetAtt": [ + "myVpcAutoPublicSubnet2EIPA484FACE", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet2" + } + ] + } + }, + "myVpcAutoPublicSubnet3SubnetF68815E0": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet3" + } + ] + } + }, + "myVpcAutoPublicSubnet3RouteTableD3E0A17D": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet3" + } + ] + } + }, + "myVpcAutoPublicSubnet3RouteTableAssociationD4E51D66": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPublicSubnet3RouteTableD3E0A17D" + }, + "SubnetId": { + "Ref": "myVpcAutoPublicSubnet3SubnetF68815E0" + } + } + }, + "myVpcAutoPublicSubnet3DefaultRouteE6596C40": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPublicSubnet3RouteTableD3E0A17D" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "myVpcAutoIGW08055396" + } + }, + "DependsOn": [ + "myVpcAutoVPCGWEC42CD12" + ] + }, + "myVpcAutoPublicSubnet3EIP8D506EFA": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet3" + } + ] + } + }, + "myVpcAutoPublicSubnet3NATGatewayC06521CD": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "myVpcAutoPublicSubnet3SubnetF68815E0" + }, + "AllocationId": { + "Fn::GetAtt": [ + "myVpcAutoPublicSubnet3EIP8D506EFA", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PublicSubnet3" + } + ] + } + }, + "myVpcAutoPrivateSubnet1SubnetCF0D49B2": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PrivateSubnet1" + } + ] + } + }, + "myVpcAutoPrivateSubnet1RouteTableDC61148B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PrivateSubnet1" + } + ] + } + }, + "myVpcAutoPrivateSubnet1RouteTableAssociation9848EFFB": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPrivateSubnet1RouteTableDC61148B" + }, + "SubnetId": { + "Ref": "myVpcAutoPrivateSubnet1SubnetCF0D49B2" + } + } + }, + "myVpcAutoPrivateSubnet1DefaultRouteF007F5E7": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPrivateSubnet1RouteTableDC61148B" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "myVpcAutoPublicSubnet1NATGatewayF3EA78A2" + } + } + }, + "myVpcAutoPrivateSubnet2Subnet592674AC": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PrivateSubnet2" + } + ] + } + }, + "myVpcAutoPrivateSubnet2RouteTableE10F6006": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PrivateSubnet2" + } + ] + } + }, + "myVpcAutoPrivateSubnet2RouteTableAssociation05CC4CEB": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPrivateSubnet2RouteTableE10F6006" + }, + "SubnetId": { + "Ref": "myVpcAutoPrivateSubnet2Subnet592674AC" + } + } + }, + "myVpcAutoPrivateSubnet2DefaultRouteDA295DF0": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPrivateSubnet2RouteTableE10F6006" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "myVpcAutoPublicSubnet2NATGatewayF670624F" + } + } + }, + "myVpcAutoPrivateSubnet3Subnet4C2A5EDE": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PrivateSubnet3" + } + ] + } + }, + "myVpcAutoPrivateSubnet3RouteTable22C6C602": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto/PrivateSubnet3" + } + ] + } + }, + "myVpcAutoPrivateSubnet3RouteTableAssociation3A3751AE": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPrivateSubnet3RouteTable22C6C602" + }, + "SubnetId": { + "Ref": "myVpcAutoPrivateSubnet3Subnet4C2A5EDE" + } + } + }, + "myVpcAutoPrivateSubnet3DefaultRouteAE484D6A": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "myVpcAutoPrivateSubnet3RouteTable22C6C602" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "myVpcAutoPublicSubnet3NATGatewayC06521CD" + } + } + }, + "myVpcAutoIGW08055396": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/myVpcAuto" + } + ] + } + }, + "myVpcAutoVPCGWEC42CD12": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + }, + "InternetGatewayId": { + "Ref": "myVpcAutoIGW08055396" + } + } + }, + "MyRoleF48FFE04": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "MyRoleDefaultPolicyA36BE1DD": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "topic2A4FB547F" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyRoleDefaultPolicyA36BE1DD", + "Roles": [ + { + "Ref": "MyRoleF48FFE04" + } + ] + } + }, + "topic69831491": { + "Type": "AWS::SNS::Topic" + }, + "topic2A4FB547F": { + "Type": "AWS::SNS::Topic" + }, + "ASGInstanceSecurityGroup0525485D": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "integ-role-target-hook/ASG/InstanceSecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/ASG" + } + ], + "VpcId": { + "Ref": "myVpcAuto1A4B61E2" + } + } + }, + "ASGInstanceRoleE263A41B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": { + "Fn::Join": [ + "", + [ + "ec2.", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + } + } + } + ], + "Version": "2012-10-17" + }, + "Tags": [ + { + "Key": "Name", + "Value": "integ-role-target-hook/ASG" + } + ] + } + }, + "ASGInstanceProfile0A2834D7": { + "Type": "AWS::IAM::InstanceProfile", + "Properties": { + "Roles": [ + { + "Ref": "ASGInstanceRoleE263A41B" + } + ] + } + }, + "ASGLaunchConfigC00AF12B": { + "Type": "AWS::AutoScaling::LaunchConfiguration", + "Properties": { + "ImageId": { + "Ref": "SsmParameterValueawsserviceamiamazonlinuxlatestamznamihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter" + }, + "InstanceType": "t2.micro", + "IamInstanceProfile": { + "Ref": "ASGInstanceProfile0A2834D7" + }, + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "ASGInstanceSecurityGroup0525485D", + "GroupId" + ] + } + ], + "UserData": { + "Fn::Base64": "#!/bin/bash" + } + }, + "DependsOn": [ + "ASGInstanceRoleE263A41B" + ] + }, + "ASG46ED3070": { + "Type": "AWS::AutoScaling::AutoScalingGroup", + "Properties": { + "MaxSize": "1", + "MinSize": "1", + "HealthCheckType": "EC2", + "LaunchConfigurationName": { + "Ref": "ASGLaunchConfigC00AF12B" + }, + "Tags": [ + { + "Key": "Name", + "PropagateAtLaunch": true, + "Value": "integ-role-target-hook/ASG" + } + ], + "VPCZoneIdentifier": [ + { + "Ref": "myVpcAutoPrivateSubnet1SubnetCF0D49B2" + }, + { + "Ref": "myVpcAutoPrivateSubnet2Subnet592674AC" + }, + { + "Ref": "myVpcAutoPrivateSubnet3Subnet4C2A5EDE" + } + ] + }, + "UpdatePolicy": { + "AutoScalingScheduledAction": { + "IgnoreUnmodifiedGroupSizeProperties": true + } + } + }, + "LCHookNoRoleNoTarget1144AD75": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "ASG46ED3070" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING" + } + }, + "LCHookNoRoleTargetRole35B4344D": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "autoscaling.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "LCHookNoRoleTargetRoleDefaultPolicyFE681941": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "topic69831491" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "LCHookNoRoleTargetRoleDefaultPolicyFE681941", + "Roles": [ + { + "Ref": "LCHookNoRoleTargetRole35B4344D" + } + ] + } + }, + "LCHookNoRoleTarget4EF682CF": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "ASG46ED3070" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "NotificationTargetARN": { + "Ref": "topic69831491" + }, + "RoleARN": { + "Fn::GetAtt": [ + "LCHookNoRoleTargetRole35B4344D", + "Arn" + ] + } + }, + "DependsOn": [ + "LCHookNoRoleTargetRoleDefaultPolicyFE681941", + "LCHookNoRoleTargetRole35B4344D" + ] + }, + "LCHookRoleTarget0ADB20B8": { + "Type": "AWS::AutoScaling::LifecycleHook", + "Properties": { + "AutoScalingGroupName": { + "Ref": "ASG46ED3070" + }, + "LifecycleTransition": "autoscaling:EC2_INSTANCE_TERMINATING", + "NotificationTargetARN": { + "Ref": "topic2A4FB547F" + }, + "RoleARN": { + "Fn::GetAtt": [ + "MyRoleF48FFE04", + "Arn" + ] + } + }, + "DependsOn": [ + "MyRoleDefaultPolicyA36BE1DD", + "MyRoleF48FFE04" + ] + } + }, + "Parameters": { + "SsmParameterValueawsserviceamiamazonlinuxlatestamznamihvmx8664gp2C96584B6F00A464EAD1953AFF4B05118Parameter": { + "Type": "AWS::SSM::Parameter::Value", + "Default": "/aws/service/ami-amazon-linux-latest/amzn-ami-hvm-x86_64-gp2" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.ts b/packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.ts new file mode 100644 index 0000000000000..42fe82d88fb6a --- /dev/null +++ b/packages/@aws-cdk/aws-autoscaling/test/integ.role-target-hook.ts @@ -0,0 +1,79 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as iam from '@aws-cdk/aws-iam'; +import * as sns from '@aws-cdk/aws-sns'; +import * as cdk from '@aws-cdk/core'; +import * as constructs from 'constructs'; +import * as autoscaling from '../lib'; + +export class FakeNotificationTarget implements autoscaling.ILifecycleHookTarget { + constructor(private readonly topic: sns.ITopic) { + } + + private createRole(scope: constructs.Construct, _role?: iam.IRole) { + let role = _role; + if (!role) { + role = new iam.Role(scope, 'Role', { + assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'), + }); + } + + return role; + } + + public bind(_scope: constructs.Construct, options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig { + const role = this.createRole(options.lifecycleHook, options.role); + this.topic.grantPublish(role); + + return { + notificationTargetArn: this.topic.topicArn, + createdRole: role, + }; + } +} + +export class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + let vpc = new ec2.Vpc(this, 'myVpcAuto', {}); + const myrole = new iam.Role(this, 'MyRole', { + assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'), + }); + const topic = new sns.Topic(this, 'topic', {}); + const topic2 = new sns.Topic(this, 'topic2', {}); + + const asg = new autoscaling.AutoScalingGroup(this, 'ASG', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.MICRO), + machineImage: new ec2.AmazonLinuxImage(), // get the latest Amazon Linux image + healthCheck: autoscaling.HealthCheck.ec2(), + }); + + // no role or notificationTarget + new autoscaling.LifecycleHook(this, 'LCHookNoRoleNoTarget', { + autoScalingGroup: asg, + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING, + }); + + // no role with notificationTarget + new autoscaling.LifecycleHook(this, 'LCHookNoRoleTarget', { + notificationTarget: new FakeNotificationTarget(topic), + autoScalingGroup: asg, + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING, + }); + + // role with target + new autoscaling.LifecycleHook(this, 'LCHookRoleTarget', { + notificationTarget: new FakeNotificationTarget(topic2), + role: myrole, + autoScalingGroup: asg, + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING, + }); + } +} + +const app = new cdk.App(); + +new TestStack(app, 'integ-role-target-hook'); + +app.synth(); diff --git a/packages/@aws-cdk/aws-autoscaling/test/lifecyclehooks.test.ts b/packages/@aws-cdk/aws-autoscaling/test/lifecyclehooks.test.ts index 7ebf6d18cbe6e..a1f3717f91042 100644 --- a/packages/@aws-cdk/aws-autoscaling/test/lifecyclehooks.test.ts +++ b/packages/@aws-cdk/aws-autoscaling/test/lifecyclehooks.test.ts @@ -1,5 +1,5 @@ import '@aws-cdk/assert-internal/jest'; -import { expect, haveResource, ResourcePart } from '@aws-cdk/assert-internal'; +import { ResourcePart } from '@aws-cdk/assert-internal'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; @@ -7,15 +7,10 @@ import * as constructs from 'constructs'; import * as autoscaling from '../lib'; describe('lifecycle hooks', () => { - test('we can add a lifecycle hook to an ASG', () => { + test('we can add a lifecycle hook with no role and with a notifcationTarget to an ASG', () => { // GIVEN const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'VPC'); - const asg = new autoscaling.AutoScalingGroup(stack, 'ASG', { - vpc, - instanceType: ec2.InstanceType.of(ec2.InstanceClass.M4, ec2.InstanceSize.MICRO), - machineImage: new ec2.AmazonLinuxImage(), - }); + const asg = newASG(stack); // WHEN asg.addLifecycleHook('Transition', { @@ -25,21 +20,22 @@ describe('lifecycle hooks', () => { }); // THEN - expect(stack).to(haveResource('AWS::AutoScaling::LifecycleHook', { + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { LifecycleTransition: 'autoscaling:EC2_INSTANCE_LAUNCHING', DefaultResult: 'ABANDON', NotificationTargetARN: 'target:arn', - })); + }); // Lifecycle Hook has a dependency on the policy object - expect(stack).to(haveResource('AWS::AutoScaling::LifecycleHook', { + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { DependsOn: [ 'ASGLifecycleHookTransitionRoleDefaultPolicy2E50C7DB', 'ASGLifecycleHookTransitionRole3AAA6BB7', ], - }, ResourcePart.CompleteDefinition)); + }, ResourcePart.CompleteDefinition); - expect(stack).to(haveResource('AWS::IAM::Role', { + // A default role is provided + expect(stack).toHaveResource('AWS::IAM::Role', { AssumeRolePolicyDocument: { Version: '2012-10-17', Statement: [ @@ -52,9 +48,10 @@ describe('lifecycle hooks', () => { }, ], }, - })); + }); - expect(stack).to(haveResource('AWS::IAM::Policy', { + // FakeNotificationTarget.bind() was executed + expect(stack).toHaveResource('AWS::IAM::Policy', { PolicyDocument: { Version: '2012-10-17', Statement: [ @@ -65,18 +62,146 @@ describe('lifecycle hooks', () => { }, ], }, - })); + }); + }); +}); + +test('we can add a lifecycle hook to an ASG with no role and with no notificationTargetArn', ()=> { + // GIVEN + const stack = new cdk.Stack(); + const asg = newASG(stack); + + // WHEN + asg.addLifecycleHook('Transition', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING, + defaultResult: autoscaling.DefaultResult.ABANDON, + }); + + // THEN + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { + LifecycleTransition: 'autoscaling:EC2_INSTANCE_LAUNCHING', + DefaultResult: 'ABANDON', + }); + + // A default role is NOT provided + expect(stack).not.toHaveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'autoscaling.amazonaws.com', + }, + }, + ], + }, + }); + + // FakeNotificationTarget.bind() was NOT executed + expect(stack).not.toHaveResource('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'action:Work', + Effect: 'Allow', + Resource: '*', + }, + ], + }, + }); +}); + +test('we can add a lifecycle hook to an ASG with a role and with a notificationTargetArn', () => { + // GIVEN + const stack = new cdk.Stack(); + const asg = newASG(stack); + const myrole = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('custom.role.domain.com'), + }); + + // WHEN + asg.addLifecycleHook('Transition', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING, + defaultResult: autoscaling.DefaultResult.ABANDON, + notificationTarget: new FakeNotificationTarget(), + role: myrole, + }); + // THEN + expect(stack).toHaveResource('AWS::AutoScaling::LifecycleHook', { + NotificationTargetARN: 'target:arn', + LifecycleTransition: 'autoscaling:EC2_INSTANCE_LAUNCHING', + DefaultResult: 'ABANDON', + }); + // the provided role (myrole), not the default role, is used + expect(stack).toHaveResource('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: 'sts:AssumeRole', + Effect: 'Allow', + Principal: { + Service: 'custom.role.domain.com', + }, + }, + ], + }, }); }); +test('adding a lifecycle hook with a role and with no notificationTarget to an ASG throws an error', () => { + // GIVEN + const stack = new cdk.Stack(); + const asg = newASG(stack); + const myrole = new iam.Role(stack, 'MyRole', { + assumedBy: new iam.ServicePrincipal('custom.role.domain.com'), + }); + + // WHEN + expect(() => { + asg.addLifecycleHook('Transition', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_LAUNCHING, + defaultResult: autoscaling.DefaultResult.ABANDON, + role: myrole, + }); + }).toThrow(/'notificationTarget' parameter required when 'role' parameter is specified/); +}); + class FakeNotificationTarget implements autoscaling.ILifecycleHookTarget { - public bind(_scope: constructs.Construct, lifecycleHook: autoscaling.ILifecycleHook): autoscaling.LifecycleHookTargetConfig { - lifecycleHook.role.addToPrincipalPolicy(new iam.PolicyStatement({ + private createRole(scope: constructs.Construct, _role?: iam.IRole) { + let role = _role; + if (!role) { + role = new iam.Role(scope, 'Role', { + assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'), + }); + } + + return role; + } + + public bind(_scope: constructs.Construct, options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig { + const role = this.createRole(options.lifecycleHook, options.role); + + role.addToPrincipalPolicy(new iam.PolicyStatement({ actions: ['action:Work'], resources: ['*'], })); - return { notificationTargetArn: 'target:arn' }; + + return { notificationTargetArn: 'target:arn', createdRole: role }; } } + +function newASG(stack: cdk.Stack) { + const vpc = new ec2.Vpc(stack, 'VPC'); + + return new autoscaling.AutoScalingGroup(stack, 'ASG', { + vpc, + instanceType: ec2.InstanceType.of(ec2.InstanceClass.M4, ec2.InstanceSize.MICRO), + machineImage: new ec2.AmazonLinuxImage(), + }); +}