From c6c594159c7fbda66f40fe8666f70b6806bb2d5e Mon Sep 17 00:00:00 2001 From: Unnati Parekh <80710604+upparekh@users.noreply.github.com> Date: Fri, 17 Sep 2021 10:12:00 -0700 Subject: [PATCH] feat(ecs-service-extensions): Publish Extension (#16326) ---- This PR adds a new service extension, `PublisherExtension`. This extension can be added to a service to allow it to publish events to SNS Topics. (This PR when paired with #16049 can be used to set up the pub/ sub architecture pattern) It sets up publish permissions for the service to be able to publish events to the topics provided. The user can also provide a list of accounts that will be given permissions to subscribe to the given topics. *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../ecs-service-extensions/README.md | 78 +- .../lib/extensions/index.ts | 3 +- .../lib/extensions/injecter.ts | 154 +++ .../test/injecter.test.ts | 114 ++ .../integ.publish-subscribe.expected.json | 996 ++++++++++++++++++ .../test/integ.publish-subscribe.ts | 69 ++ 6 files changed, 1411 insertions(+), 3 deletions(-) create mode 100644 packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/injecter.ts create mode 100644 packages/@aws-cdk-containers/ecs-service-extensions/test/injecter.test.ts create mode 100644 packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.expected.json create mode 100644 packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.ts diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/README.md b/packages/@aws-cdk-containers/ecs-service-extensions/README.md index 53e4d2b6f3c56..0556debfc705f 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/README.md +++ b/packages/@aws-cdk-containers/ecs-service-extensions/README.md @@ -19,7 +19,8 @@ The `Service` construct provided by this module can be extended with optional `S - [AWS AppMesh](https://aws.amazon.com/app-mesh/) for adding your application to a service mesh - [Application Load Balancer](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html), for exposing your service to the public - [AWS FireLens](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/using_firelens.html), for filtering and routing application logs -- Queue to allow your service to consume messages from an SQS Queue which is populated by one or more SNS Topics that it is subscribed to +- [Injecter Extension](#injecter-extension), for allowing your service connect to other AWS services by granting permission and injecting environment variables +- [Queue Extension](#queue-extension), for allowing your service to consume messages from an SQS Queue which can be populated by one or more SNS Topics that it is subscribed to - [Community Extensions](#community-extensions), providing support for advanced use cases The `ServiceExtension` class is an abstract class which you can also implement in @@ -322,9 +323,28 @@ const environment = Environment.fromEnvironmentAttributes(stack, 'Environment', ``` +## Injecter Extension + +This service extension accepts a list of `Injectable` resources. It grants access to these resources and adds the necessary environment variables to the tasks that are part of the service. + +For example, an `InjectableTopic` is an SNS Topic that grants permission to the task role and adds the topic ARN as an environment variable to the task definition. + +### Publishing to SNS Topics + +You can use this extension to set up publishing permissions for SNS Topics. + +```ts +nameDescription.add(new InjecterExtension({ + injectables: [new InjectableTopic({ + // SNS Topic the service will publish to + topic: new sns.Topic(stack, 'my-topic'), + })], +})); +``` + ## Queue Extension -This service extension creates a default SQS Queue `eventsQueue` for the service (if not provided) and accepts a list of `ISubscribable` objects that the `eventsQueue` can subscribe to. The service extension creates the subscriptions and sets up permissions for the service to consume messages from the SQS Queue. +This service extension creates a default SQS Queue `eventsQueue` for the service (if not provided) and optionally also accepts list of `ISubscribable` objects that the `eventsQueue` can subscribe to. The service extension creates the subscriptions and sets up permissions for the service to consume messages from the SQS Queue. ### Setting up SNS Topic Subscriptions for SQS Queues @@ -356,6 +376,60 @@ nameDescription.add(new QueueExtension({ })); ``` +## Publish/Subscribe Service Pattern + +The [Publish/Subscribe Service Pattern](https://aws.amazon.com/pub-sub-messaging/) is used for implementing asynchronous communication between services. It involves 'publisher' services emitting events to SNS Topics, which are passed to subscribed SQS queues and then consumed by 'worker' services. + +The following example adds the `InjecterExtension` to a `Publisher` Service which can publish events to an SNS Topic and adds the `QueueExtension` to a `Worker` Service which can poll its `eventsQueue` to consume messages populated by the topic. + +```ts +const environment = new Environment(stack, 'production'); + +const pubServiceDescription = new ServiceDescription(); +pubServiceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('sns-publish'), +})); + +const myTopic = new sns.Topic(stack, 'myTopic'); + +// Add the `InjecterExtension` to the service description to allow publishing events to `myTopic` +pubServiceDescription.add(new InjecterExtension({ + injectables: [new InjectableTopic({ + topic: myTopic, + }], +})); + +// Create the `Publisher` Service +new Service(stack, 'Publisher', { + environment: environment, + serviceDescription: pubServiceDescription, +}); + +const subServiceDescription = new ServiceDescription(); +subServiceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('sqs-reader'), +})); + +// Add the `QueueExtension` to the service description to subscribe to `myTopic` +subServiceDescription.add(new QueueExtension({ + subscriptions: [new TopicSubscription({ + topic: myTopic, + }], +})); + +// Create the `Worker` Service +new Service(stack, 'Worker', { + environment: environment, + serviceDescription: subServiceDescription, +}); +``` + ## Community Extensions We encourage the development of Community Service Extensions that support diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/index.ts b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/index.ts index 78c138aba0102..2191def8aaa15 100644 --- a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/index.ts +++ b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/index.ts @@ -6,4 +6,5 @@ export * from './cloudwatch-agent'; export * from './scale-on-cpu-utilization'; export * from './xray'; export * from './assign-public-ip'; -export * from './queue'; \ No newline at end of file +export * from './queue'; +export * from './injecter'; diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/injecter.ts b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/injecter.ts new file mode 100644 index 0000000000000..b7c224f52d9f7 --- /dev/null +++ b/packages/@aws-cdk-containers/ecs-service-extensions/lib/extensions/injecter.ts @@ -0,0 +1,154 @@ +import * as ecs from '@aws-cdk/aws-ecs'; +import * as sns from '@aws-cdk/aws-sns'; +import { Service } from '../service'; +import { Container } from './container'; +import { ContainerMutatingHook, ServiceExtension } from './extension-interfaces'; + +// 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'; + +/** + * An interface that will be implemented by all the resources that can be published events or written data to. + */ +export interface Injectable { + environmentVariables(): { [key: string]: string }; +} + +/** + * An interface that will be implemented by all the injectable resources that need to grant permissions to the task role. + */ +export interface GrantInjectable extends Injectable { + grant(taskDefinition: ecs.TaskDefinition): void; +} + +/** + * The settings for the `InjectableTopic` class. + */ +export interface InjectableTopicProps { + /** + * The SNS Topic to publish events to. + */ + readonly topic: sns.ITopic; +} + +/** + * The `InjectableTopic` class represents SNS Topic resource that can be published events to by the parent service. + */ + +export class InjectableTopic implements GrantInjectable { + public readonly topic: sns.ITopic; + + constructor(props: InjectableTopicProps) { + this.topic = props.topic; + } + + public grant(taskDefinition: ecs.TaskDefinition) { + this.topic.grantPublish(taskDefinition.taskRole); + } + + public environmentVariables(): { [key: string]: string } { + let environment: { [key: string]: string } = {}; + environment[`${this.topic.node.id.toUpperCase()}_TOPIC_ARN`] = this.topic.topicArn; + return environment; + } +} + +/** + * The settings for the Injecter extension. + */ +export interface InjecterExtensionProps { + /** + * The list of injectable resources for this service. + */ + readonly injectables: Injectable[]; +} + +/** + * Settings for the hook which mutates the application container + * to add the injectable resource environment variables. + */ +interface ContainerMutatingProps { + /** + * The resource environment variables to be added to the container environment. + */ + readonly environment: { [key: string]: string }; +} + +/** + * This hook modifies the application container's environment to + * add the injectable resource environment variables. + */ +class InjecterExtensionMutatingHook extends ContainerMutatingHook { + private environment: { [key: string]: string }; + + constructor(props: ContainerMutatingProps) { + super(); + this.environment = props.environment; + } + + public mutateContainerDefinition(props: ecs.ContainerDefinitionOptions): ecs.ContainerDefinitionOptions { + return { + ...props, + + environment: { ...(props.environment || {}), ...this.environment }, + } as ecs.ContainerDefinitionOptions; + } +} + +/** + * This extension accepts a list of `Injectable` resources that the parent service can publish events or write data to. + * It sets up the corresponding permissions for the task role of the parent service. + */ +export class InjecterExtension extends ServiceExtension { + private props: InjecterExtensionProps; + + private environment: { [key: string]: string } = {}; + + constructor(props: InjecterExtensionProps) { + super('injecter'); + + this.props = props; + } + + // @ts-ignore - Ignore unused params that are required for abstract class extend + public prehook(service: Service, scope: Construct) { + this.parentService = service; + + for (const injectable of this.props.injectables) { + for (const [key, val] of Object.entries(injectable.environmentVariables())) { + this.environment[key] = val; + } + } + } + + /** + * Add hooks to the main application extension so that it is modified to + * add the injectable resource environment variables to the container environment. + */ + public addHooks() { + const container = this.parentService.serviceDescription.get('service-container') as Container; + + if (!container) { + throw new Error('Injecter Extension requires an application extension'); + } + + container.addContainerMutatingHook(new InjecterExtensionMutatingHook({ + environment: this.environment, + })); + } + + /** + * After the task definition has been created, this hook grants the required permissions to the task role for the + * parent service. + * + * @param taskDefinition The created task definition + */ + public useTaskDefinition(taskDefinition: ecs.TaskDefinition) { + for (const injectable of this.props.injectables) { + if ((injectable as GrantInjectable).grant !== undefined) { + (injectable as GrantInjectable).grant(taskDefinition); + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/injecter.test.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/injecter.test.ts new file mode 100644 index 0000000000000..3cfd8f4918f48 --- /dev/null +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/injecter.test.ts @@ -0,0 +1,114 @@ +import { countResources, expect, haveResource } from '@aws-cdk/assert-internal'; +import * as ecs from '@aws-cdk/aws-ecs'; +import * as sns from '@aws-cdk/aws-sns'; +import * as cdk from '@aws-cdk/core'; +import { Container, Environment, InjecterExtension, InjectableTopic, Service, ServiceDescription } from '../lib'; + +describe('injecter', () => { + test('correctly sets publish permissions for given topics', () => { + // GIVEN + const stack = new cdk.Stack(); + + const environment = new Environment(stack, 'production'); + const serviceDescription = new ServiceDescription(); + + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + environment: { + PORT: '80', + }, + })); + + // WHEN + const topic1 = new InjectableTopic({ + topic: new sns.Topic(stack, 'topic1'), + }); + + const topic2 = new InjectableTopic({ + topic: new sns.Topic(stack, 'topic2'), + }); + + serviceDescription.add(new InjecterExtension({ + injectables: [topic1, topic2], + })); + + new Service(stack, 'my-service', { + environment, + serviceDescription, + }); + + // THEN + // Ensure creation of provided topics + expect(stack).to(countResources('AWS::SNS::Topic', 2)); + + // Ensure the task role is given permissions to publish events to topics + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Statement: [ + { + Action: 'sns:Publish', + Effect: 'Allow', + Resource: { + Ref: 'topic152D84A37', + }, + }, + { + Action: 'sns:Publish', + Effect: 'Allow', + Resource: { + Ref: 'topic2A4FB547F', + }, + }, + ], + Version: '2012-10-17', + }, + })); + + // Ensure that the topic ARNs have been correctly appended to the environment variables + expect(stack).to(haveResource('AWS::ECS::TaskDefinition', { + ContainerDefinitions: [ + { + Cpu: 256, + Environment: [ + { + Name: 'PORT', + Value: '80', + }, + { + Name: 'TOPIC1_TOPIC_ARN', + Value: { + Ref: 'topic152D84A37', + }, + }, + { + Name: 'TOPIC2_TOPIC_ARN', + Value: { + Ref: 'topic2A4FB547F', + }, + }, + ], + Image: 'nathanpeck/name', + Essential: true, + Memory: 512, + Name: 'app', + PortMappings: [ + { + ContainerPort: 80, + Protocol: 'tcp', + }, + ], + Ulimits: [ + { + HardLimit: 1024000, + Name: 'nofile', + SoftLimit: 1024000, + }, + ], + }, + ], + })); + }); +}); \ No newline at end of file diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.expected.json b/packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.expected.json new file mode 100644 index 0000000000000..4fa8a31db3ba9 --- /dev/null +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.expected.json @@ -0,0 +1,996 @@ +{ + "Resources": { + "productionenvironmentvpcAEB47DF7": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet1Subnet8D92C089": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet1" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet1RouteTable6E9ABC21": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet1" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet1RouteTableAssociationA8117374": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPublicSubnet1RouteTable6E9ABC21" + }, + "SubnetId": { + "Ref": "productionenvironmentvpcPublicSubnet1Subnet8D92C089" + } + } + }, + "productionenvironmentvpcPublicSubnet1DefaultRoute524C894D": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPublicSubnet1RouteTable6E9ABC21" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "productionenvironmentvpcIGWE7C39890" + } + }, + "DependsOn": [ + "productionenvironmentvpcVPCGW1B428D07" + ] + }, + "productionenvironmentvpcPublicSubnet1EIP54BA88DB": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet1" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet1NATGateway6075E4CA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "productionenvironmentvpcPublicSubnet1Subnet8D92C089" + }, + "AllocationId": { + "Fn::GetAtt": [ + "productionenvironmentvpcPublicSubnet1EIP54BA88DB", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet1" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet2Subnet298E6C31": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet2" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet2RouteTable842A68D7": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet2" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet2RouteTableAssociation0A7549F3": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPublicSubnet2RouteTable842A68D7" + }, + "SubnetId": { + "Ref": "productionenvironmentvpcPublicSubnet2Subnet298E6C31" + } + } + }, + "productionenvironmentvpcPublicSubnet2DefaultRoute92CD697D": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPublicSubnet2RouteTable842A68D7" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "productionenvironmentvpcIGWE7C39890" + } + }, + "DependsOn": [ + "productionenvironmentvpcVPCGW1B428D07" + ] + }, + "productionenvironmentvpcPublicSubnet2EIP14CA46AA": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet2" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet2NATGatewayE1850FCC": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "productionenvironmentvpcPublicSubnet2Subnet298E6C31" + }, + "AllocationId": { + "Fn::GetAtt": [ + "productionenvironmentvpcPublicSubnet2EIP14CA46AA", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet2" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet3SubnetC7B5665D": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet3" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet3RouteTable00E3BF60": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet3" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet3RouteTableAssociationFA34D6E7": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPublicSubnet3RouteTable00E3BF60" + }, + "SubnetId": { + "Ref": "productionenvironmentvpcPublicSubnet3SubnetC7B5665D" + } + } + }, + "productionenvironmentvpcPublicSubnet3DefaultRouteE1ADEA6C": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPublicSubnet3RouteTable00E3BF60" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "productionenvironmentvpcIGWE7C39890" + } + }, + "DependsOn": [ + "productionenvironmentvpcVPCGW1B428D07" + ] + }, + "productionenvironmentvpcPublicSubnet3EIP53405AED": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet3" + } + ] + } + }, + "productionenvironmentvpcPublicSubnet3NATGateway94604057": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "SubnetId": { + "Ref": "productionenvironmentvpcPublicSubnet3SubnetC7B5665D" + }, + "AllocationId": { + "Fn::GetAtt": [ + "productionenvironmentvpcPublicSubnet3EIP53405AED", + "AllocationId" + ] + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PublicSubnet3" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet1Subnet53F632E6": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PrivateSubnet1" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet1RouteTable2C6DFF0C": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PrivateSubnet1" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet1RouteTableAssociation8BA32463": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPrivateSubnet1RouteTable2C6DFF0C" + }, + "SubnetId": { + "Ref": "productionenvironmentvpcPrivateSubnet1Subnet53F632E6" + } + } + }, + "productionenvironmentvpcPrivateSubnet1DefaultRouteFBB3DE6C": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPrivateSubnet1RouteTable2C6DFF0C" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "productionenvironmentvpcPublicSubnet1NATGateway6075E4CA" + } + } + }, + "productionenvironmentvpcPrivateSubnet2Subnet756FB93C": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PrivateSubnet2" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet2RouteTable2F77D0D2": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PrivateSubnet2" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet2RouteTableAssociation09188261": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPrivateSubnet2RouteTable2F77D0D2" + }, + "SubnetId": { + "Ref": "productionenvironmentvpcPrivateSubnet2Subnet756FB93C" + } + } + }, + "productionenvironmentvpcPrivateSubnet2DefaultRoute5F9AB6C1": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPrivateSubnet2RouteTable2F77D0D2" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "productionenvironmentvpcPublicSubnet2NATGatewayE1850FCC" + } + } + }, + "productionenvironmentvpcPrivateSubnet3Subnet3BD4064E": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PrivateSubnet3" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet3RouteTable1A244D34": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc/PrivateSubnet3" + } + ] + } + }, + "productionenvironmentvpcPrivateSubnet3RouteTableAssociation65F18B9C": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPrivateSubnet3RouteTable1A244D34" + }, + "SubnetId": { + "Ref": "productionenvironmentvpcPrivateSubnet3Subnet3BD4064E" + } + } + }, + "productionenvironmentvpcPrivateSubnet3DefaultRoute2438918B": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "productionenvironmentvpcPrivateSubnet3RouteTable1A244D34" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "productionenvironmentvpcPublicSubnet3NATGateway94604057" + } + } + }, + "productionenvironmentvpcIGWE7C39890": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-ecs-integ/production-environment-vpc" + } + ] + } + }, + "productionenvironmentvpcVPCGW1B428D07": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + }, + "InternetGatewayId": { + "Ref": "productionenvironmentvpcIGWE7C39890" + } + } + }, + "productionenvironmentclusterC6599D2D": { + "Type": "AWS::ECS::Cluster" + }, + "signupD2AAA171": { + "Type": "AWS::SNS::Topic" + }, + "delete1CCE71FF": { + "Type": "AWS::SNS::Topic" + }, + "PublishertaskdefinitionTaskRoleE8655AA5": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "PublishertaskdefinitionTaskRoleDefaultPolicyD6E49F15": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "signupD2AAA171" + } + }, + { + "Action": "sns:Publish", + "Effect": "Allow", + "Resource": { + "Ref": "delete1CCE71FF" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "PublishertaskdefinitionTaskRoleDefaultPolicyD6E49F15", + "Roles": [ + { + "Ref": "PublishertaskdefinitionTaskRoleE8655AA5" + } + ] + } + }, + "PublishertaskdefinitionA4324C64": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Cpu": 256, + "Environment": [ + { + "Name": "PORT", + "Value": "80" + }, + { + "Name": "SIGN-UP_TOPIC_ARN", + "Value": { + "Ref": "signupD2AAA171" + } + }, + { + "Name": "DELETE_TOPIC_ARN", + "Value": { + "Ref": "delete1CCE71FF" + } + } + ], + "Essential": true, + "Image": "nathanpeck/name", + "Memory": 512, + "Name": "app", + "PortMappings": [ + { + "ContainerPort": 80, + "Protocol": "tcp" + } + ], + "Ulimits": [ + { + "HardLimit": 1024000, + "Name": "nofile", + "SoftLimit": 1024000 + } + ] + } + ], + "Cpu": "256", + "Family": "awsecsintegPublishertaskdefinitionD50610D0", + "Memory": "512", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "EC2", + "FARGATE" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "PublishertaskdefinitionTaskRoleE8655AA5", + "Arn" + ] + } + } + }, + "PublisherserviceService9EB00F60": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "productionenvironmentclusterC6599D2D" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 100 + }, + "DesiredCount": 1, + "EnableECSManagedTags": false, + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "PublisherserviceSecurityGroupC7B0C0D0", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "productionenvironmentvpcPrivateSubnet1Subnet53F632E6" + }, + { + "Ref": "productionenvironmentvpcPrivateSubnet2Subnet756FB93C" + }, + { + "Ref": "productionenvironmentvpcPrivateSubnet3Subnet3BD4064E" + } + ] + } + }, + "TaskDefinition": { + "Ref": "PublishertaskdefinitionA4324C64" + } + } + }, + "PublisherserviceSecurityGroupC7B0C0D0": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/Publisher-service/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + } + } + }, + "signupqueue33AFF2E6": { + "Type": "AWS::SQS::Queue", + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "signupqueuePolicy185ADC00": { + "Type": "AWS::SQS::QueuePolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sqs:SendMessage", + "Condition": { + "ArnEquals": { + "aws:SourceArn": { + "Ref": "signupD2AAA171" + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Resource": { + "Fn::GetAtt": [ + "signupqueue33AFF2E6", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "Queues": [ + { + "Ref": "signupqueue33AFF2E6" + } + ] + } + }, + "signupqueueawsecsintegsignup8DE00B29CE828029": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "sqs", + "TopicArn": { + "Ref": "signupD2AAA171" + }, + "Endpoint": { + "Fn::GetAtt": [ + "signupqueue33AFF2E6", + "Arn" + ] + } + } + }, + "EventsDeadLetterQueue404572C7": { + "Type": "AWS::SQS::Queue", + "Properties": { + "MessageRetentionPeriod": 1209600 + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "EventsQueueB96EB0D2": { + "Type": "AWS::SQS::Queue", + "Properties": { + "RedrivePolicy": { + "deadLetterTargetArn": { + "Fn::GetAtt": [ + "EventsDeadLetterQueue404572C7", + "Arn" + ] + }, + "maxReceiveCount": 3 + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" + }, + "EventsQueuePolicyF3E925EC": { + "Type": "AWS::SQS::QueuePolicy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "sqs:SendMessage", + "Condition": { + "ArnEquals": { + "aws:SourceArn": { + "Ref": "delete1CCE71FF" + } + } + }, + "Effect": "Allow", + "Principal": { + "Service": "sns.amazonaws.com" + }, + "Resource": { + "Fn::GetAtt": [ + "EventsQueueB96EB0D2", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "Queues": [ + { + "Ref": "EventsQueueB96EB0D2" + } + ] + } + }, + "EventsQueueawsecsintegdeleteF56807768162F4C0": { + "Type": "AWS::SNS::Subscription", + "Properties": { + "Protocol": "sqs", + "TopicArn": { + "Ref": "delete1CCE71FF" + }, + "Endpoint": { + "Fn::GetAtt": [ + "EventsQueueB96EB0D2", + "Arn" + ] + } + } + }, + "WorkertaskdefinitionTaskRole1EBF20D6": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "WorkertaskdefinitionTaskRoleDefaultPolicy45EAFD8C": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:ChangeMessageVisibility", + "sqs:GetQueueUrl", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "EventsQueueB96EB0D2", + "Arn" + ] + } + }, + { + "Action": [ + "sqs:ReceiveMessage", + "sqs:ChangeMessageVisibility", + "sqs:GetQueueUrl", + "sqs:DeleteMessage", + "sqs:GetQueueAttributes" + ], + "Effect": "Allow", + "Resource": { + "Fn::GetAtt": [ + "signupqueue33AFF2E6", + "Arn" + ] + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "WorkertaskdefinitionTaskRoleDefaultPolicy45EAFD8C", + "Roles": [ + { + "Ref": "WorkertaskdefinitionTaskRole1EBF20D6" + } + ] + } + }, + "WorkertaskdefinitionBF93A675": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Cpu": 256, + "Environment": [ + { + "Name": "PORT", + "Value": "80" + }, + { + "Name": "WORKER_QUEUE_URI", + "Value": { + "Ref": "EventsQueueB96EB0D2" + } + } + ], + "Essential": true, + "Image": "nathanpeck/name", + "Memory": 512, + "Name": "app", + "PortMappings": [ + { + "ContainerPort": 80, + "Protocol": "tcp" + } + ], + "Ulimits": [ + { + "HardLimit": 1024000, + "Name": "nofile", + "SoftLimit": 1024000 + } + ] + } + ], + "Cpu": "256", + "Family": "awsecsintegWorkertaskdefinition32B60762", + "Memory": "512", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "EC2", + "FARGATE" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "WorkertaskdefinitionTaskRole1EBF20D6", + "Arn" + ] + } + } + }, + "WorkerserviceService68C5A5C3": { + "Type": "AWS::ECS::Service", + "Properties": { + "Cluster": { + "Ref": "productionenvironmentclusterC6599D2D" + }, + "DeploymentConfiguration": { + "MaximumPercent": 200, + "MinimumHealthyPercent": 100 + }, + "DesiredCount": 1, + "EnableECSManagedTags": false, + "LaunchType": "FARGATE", + "NetworkConfiguration": { + "AwsvpcConfiguration": { + "AssignPublicIp": "DISABLED", + "SecurityGroups": [ + { + "Fn::GetAtt": [ + "WorkerserviceSecurityGroup1CDDB904", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "productionenvironmentvpcPrivateSubnet1Subnet53F632E6" + }, + { + "Ref": "productionenvironmentvpcPrivateSubnet2Subnet756FB93C" + }, + { + "Ref": "productionenvironmentvpcPrivateSubnet3Subnet3BD4064E" + } + ] + } + }, + "TaskDefinition": { + "Ref": "WorkertaskdefinitionBF93A675" + } + } + }, + "WorkerserviceSecurityGroup1CDDB904": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/Worker-service/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.ts b/packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.ts new file mode 100644 index 0000000000000..acfdae2437646 --- /dev/null +++ b/packages/@aws-cdk-containers/ecs-service-extensions/test/integ.publish-subscribe.ts @@ -0,0 +1,69 @@ +import * as ecs from '@aws-cdk/aws-ecs'; +import * as sns from '@aws-cdk/aws-sns'; +import * as sqs from '@aws-cdk/aws-sqs'; +import * as cdk from '@aws-cdk/core'; +import { Container, Environment, InjecterExtension, InjectableTopic, QueueExtension, Service, ServiceDescription, TopicSubscription } from '../lib'; + + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-ecs-integ'); + +const environment = new Environment(stack, 'production'); + +const pubServiceDescription = new ServiceDescription(); + +pubServiceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + environment: { + PORT: '80', + }, +})); + +const topic1 = new InjectableTopic({ + topic: new sns.Topic(stack, 'sign-up'), +}); + +const topic2 = new InjectableTopic({ + topic: new sns.Topic(stack, 'delete'), +}); + +pubServiceDescription.add(new InjecterExtension({ + injectables: [topic1, topic2], +})); + +new Service(stack, 'Publisher', { + environment: environment, + serviceDescription: pubServiceDescription, +}); + +const subServiceDescription = new ServiceDescription(); + +subServiceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + environment: { + PORT: '80', + }, +})); + +const topicSubscription1 = new TopicSubscription({ + topic: topic1.topic, + queue: new sqs.Queue(stack, 'sign-up-queue'), +}); +const topicSubscription2 = new TopicSubscription({ + topic: topic2.topic, +}); + +subServiceDescription.add(new QueueExtension({ + subscriptions: [topicSubscription1, topicSubscription2], +})); + +new Service(stack, 'Worker', { + environment: environment, + serviceDescription: subServiceDescription, +}); \ No newline at end of file