diff --git a/API.md b/API.md index 5be1b2ae..c2094c02 100644 --- a/API.md +++ b/API.md @@ -426,6 +426,7 @@ new Service(scope: Construct, id: string, props: ServiceProps) | addURL | This method adds a new URL for the service. | | connectTo | Tell extensions from one service to connect to extensions from another sevice if they have implemented a hook for it. | | enableAutoScalingPolicy | This helper method is used to set the `autoScalingPoliciesEnabled` attribute whenever an auto scaling policy is configured for the service. | +| enableServiceConnect | This method allows a service to opt in to ECS Service Connect as a client. | | getURL | Retrieve a URL for the service. | --- @@ -494,6 +495,17 @@ public enableAutoScalingPolicy(): void This helper method is used to set the `autoScalingPoliciesEnabled` attribute whenever an auto scaling policy is configured for the service. +##### `enableServiceConnect` + +```typescript +public enableServiceConnect(): void +``` + +This method allows a service to opt in to ECS Service Connect as a client. + +If this method is not called, the service will not be able to reach other +Service Connect enabled services via their terse DNS aliases. + ##### `getURL` ```typescript @@ -1818,6 +1830,7 @@ const serviceProps: ServiceProps = { ... } | serviceDescription | ServiceDescription | The ServiceDescription used to build the service. | | autoScaleTaskCount | AutoScalingOptions | The options for configuring the auto scaling target. | | desiredCount | number | The desired number of instantiations of the task definition to keep running on the service. | +| enableServiceConnect | boolean | Whether to opt this service in to Service Connect as a client. | | taskRole | aws-cdk-lib.aws_iam.IRole | The name of the IAM role that grants containers in the task permission to call AWS APIs on your behalf. | --- @@ -1872,6 +1885,19 @@ The desired number of instantiations of the task definition to keep running on t --- +##### `enableServiceConnect`Optional + +```typescript +public readonly enableServiceConnect: boolean; +``` + +- *Type:* boolean +- *Default:* true if an AliasedPortExtension was added to the service description, otherwise false + +Whether to opt this service in to Service Connect as a client. + +--- + ##### `taskRole`Optional ```typescript @@ -1999,6 +2025,20 @@ If not provided, the default `eventsQueue` will subscribe to the given topic. ### AliasedPortExtension +AliasedPortExtension allows services to opt in to Amazon ECS Service Connect using a terse DNS alias, an optional protocol, and a port over which the service will receive Service Connect traffic. + +*Example* + +```typescript +declare const description: ServiceDescription; +description.add(new AliasedPortExtension({ + alias: 'backend-api', + appProtocol: ecs.AppProtocol.grpc, + aliasPort: 80, +})); +``` + + #### Initializers ```typescript @@ -2166,8 +2206,6 @@ create any final resources which might depend on the service itself. - *Type:* aws-cdk-lib.aws_ecs.Ec2Service | aws-cdk-lib.aws_ecs.FargateService -The generated service. - --- ##### `useTaskDefinition` diff --git a/README.md b/README.md index ac421daa..ad4dd0b4 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,51 @@ new Service(this, 'Worker', { }); ``` +## Aliased Port Extension +[Amazon ECS Service Connect](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/service-connect.html#service-connect-concepts) is a simple-to-use managed service mesh offering. It involves the creation of a CloudMap Namespace in a service's environment, then the creation of one or more Service Connect Services which route traffic to any named port mapping on the service's task definition. + +The following example adds an `AliasedPortExtension` to a Service, allowing other services which have opted in to Service Connect to reach it through its terse DNS alias. + +```ts +const environment = new Environment(this, 'production'); + +const serverDescription = new ServiceDescription() ; +serverDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), +})); +serverDescription.add(new AliasedPortExtension({ + alias: 'server', +})); + +new Service(this, 'Server', { + environment, + serviceDescription: serverDescription +}); + +const clientDescription = new ServiceDescription(); +clientDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/greeter'), + environment: { + PORT: '80', + NAME_URL: 'http://server' + }, +})); + +const clientService = new Service(this, 'client', { + environment, + serviceDescription: clientDescription, +}); +clientService.enableServiceConnect(); +``` + +In the example above, the `server` service advertises its port `80` via a terse DNS alias `server`. The client opts in to ECS Service Connect and uses the short URL and port to access the server service. The `AliasedPortExtension` creates the necessary named port mapping on the Task Definition, adds a default CloudMap namespace to the environment, and registers the Service Connect Service under the container's `alias` and `trafficPort`. + ## Community Extensions We encourage the development of Community Service Extensions that support diff --git a/src/extensions/aliased-port.ts b/src/extensions/aliased-port.ts index 1f9def11..ac14b37d 100644 --- a/src/extensions/aliased-port.ts +++ b/src/extensions/aliased-port.ts @@ -1,4 +1,5 @@ import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as cloudmap from 'aws-cdk-lib/aws-servicediscovery'; import { Construct } from 'constructs'; import { Service } from '../service'; import { Container } from './container'; @@ -6,7 +7,7 @@ import { ContainerMutatingHook, ServiceBuild, ServiceExtension } from './extensi /** - * AliasedPortProps defines the properties of an aliased port extension + * AliasedPortProps defines the properties of an aliased port extension. */ export interface AliasedPortProps { /** @@ -30,11 +31,24 @@ export interface AliasedPortProps { readonly aliasPort?: number; } +/** + * AliasedPortExtension allows services to opt in to Amazon ECS Service Connect using a terse DNS alias, + * an optional protocol, and a port over which the service will receive Service Connect traffic. + * + * @example + * + * declare const description: ServiceDescription; + * description.add(new AliasedPortExtension({ + * alias: 'backend-api', + * appProtocol: ecs.AppProtocol.grpc, + * aliasPort: 80, + * })); + */ export class AliasedPortExtension extends ServiceExtension { protected alias: string; protected aliasPort?: number; protected appProtocol?: ecs.AppProtocol; - protected namespace?: string; + protected namespace?: cloudmap.INamespace; constructor(props: AliasedPortProps) { super('aliasedPort'); @@ -49,12 +63,12 @@ export class AliasedPortExtension extends ServiceExtension { this.scope = scope; // If there isn't a default cloudmap namespace on the cluster, create a private HTTP namespace for SC. - if (!this.parentService.cluster.defaultCloudMapNamespace) { + if (!this.parentService.environment.cluster.defaultCloudMapNamespace) { this.parentService.environment.addDefaultCloudMapNamespace({ name: this.parentService.environment.id, }); } - this.namespace = this.parentService.environment.cluster.defaultCloudMapNamespace?.namespaceName; + this.namespace = this.parentService.environment.cluster.defaultCloudMapNamespace as cloudmap.INamespace; } public addHooks(): void { @@ -71,7 +85,7 @@ export class AliasedPortExtension extends ServiceExtension { } public modifyServiceProps(props: ServiceBuild): ServiceBuild { - if (props.serviceConnectConfiguration && props.serviceConnectConfiguration.namespace !== this.namespace) { + if (props.serviceConnectConfiguration && props.serviceConnectConfiguration.namespace !== this.namespace?.namespaceName) { throw new Error('Service connect cannot be enabled with two different namespaces.'); } @@ -118,6 +132,13 @@ export class AliasedPortExtension extends ServiceExtension { }, }; } + + public useService(service: ecs.Ec2Service | ecs.FargateService): void { + if (!this.namespace) { + throw new Error('Environment must have a default Cloudmap namespace to enable Service Connect.'); + } + service.node.addDependency(this.namespace); + } } export interface AliasedPortMutatingHookProps { diff --git a/src/service.ts b/src/service.ts index 37a9f96a..4a86eb3d 100644 --- a/src/service.ts +++ b/src/service.ts @@ -55,6 +55,13 @@ export interface ServiceProps { * @default none */ readonly autoScaleTaskCount?: AutoScalingOptions; + + /** + * Whether to opt this service in to Service Connect as a client. + * + * @default - true if an AliasedPortExtension was added to the service description, otherwise false + */ + readonly enableServiceConnect?: boolean; } export interface AutoScalingOptions { @@ -291,6 +298,11 @@ export class Service extends Construct { throw new Error(`Unknown capacity type for service ${this.id}`); } + // Enable service connect if requested and it has not been enabled by an extension. + if (props.enableServiceConnect && !serviceProps.serviceConnectConfiguration) { + this.enableServiceConnect(); + } + // Create the auto scaling target and configure target tracking policies after the service is created if (props.autoScaleTaskCount) { this.scalableTaskCount = this.ecsService.autoScaleTaskCount({ @@ -375,4 +387,19 @@ export class Service extends Construct { public enableAutoScalingPolicy() { this.autoScalingPoliciesEnabled = true; } + + /** + * This method allows a service to opt in to ECS Service Connect as a client. + * If this method is not called, the service will not be able to reach other + * Service Connect enabled services via their terse DNS aliases. + */ + public enableServiceConnect() { + if (!this.environment.cluster.defaultCloudMapNamespace) { + throw new Error('Environment must have a default CloudMap namespace to enable Service Connect.'); + } + this.ecsService.enableServiceConnect({ + namespace: this.environment.cluster.defaultCloudMapNamespace.namespaceName, + }); + this.ecsService.node.addDependency(this.environment.cluster.defaultCloudMapNamespace); + } } diff --git a/test/aliased-port.integ.snapshot/aws-ecs-integ.assets.json b/test/aliased-port.integ.snapshot/aws-ecs-integ.assets.json index 9fb3a734..36e52720 100644 --- a/test/aliased-port.integ.snapshot/aws-ecs-integ.assets.json +++ b/test/aliased-port.integ.snapshot/aws-ecs-integ.assets.json @@ -1,7 +1,7 @@ { "version": "21.0.0", "files": { - "1904e1ff90c4f297a8a8c9f4f5573101aa4cb632abb96486f141dc588c222614": { + "4ac410b83bf75ad3eee119980f62663b332db0d347a80e1c7a1493bf14772c05": { "source": { "path": "aws-ecs-integ.template.json", "packaging": "file" @@ -9,7 +9,7 @@ "destinations": { "current_account-current_region": { "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "1904e1ff90c4f297a8a8c9f4f5573101aa4cb632abb96486f141dc588c222614.json", + "objectKey": "4ac410b83bf75ad3eee119980f62663b332db0d347a80e1c7a1493bf14772c05.json", "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" } } diff --git a/test/aliased-port.integ.snapshot/aws-ecs-integ.template.json b/test/aliased-port.integ.snapshot/aws-ecs-integ.template.json index 8563f25f..63902112 100644 --- a/test/aliased-port.integ.snapshot/aws-ecs-integ.template.json +++ b/test/aliased-port.integ.snapshot/aws-ecs-integ.template.json @@ -521,7 +521,10 @@ "TaskDefinition": { "Ref": "ServiceConnecttaskdefinitionB19E0536" } - } + }, + "DependsOn": [ + "productionenvironmentclusterDefaultServiceDiscoveryNamespaceBE74D64D" + ] }, "ServiceConnectserviceSecurityGroup0D1FCAE3": { "Type": "AWS::EC2::SecurityGroup", @@ -537,7 +540,143 @@ "VpcId": { "Ref": "productionenvironmentvpcAEB47DF7" } + }, + "DependsOn": [ + "productionenvironmentclusterDefaultServiceDiscoveryNamespaceBE74D64D" + ] + }, + "ClientServicetaskdefinitionTaskRoleACC19FE3": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "ecs-tasks.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + } + } + }, + "ClientServicetaskdefinition89718CF4": { + "Type": "AWS::ECS::TaskDefinition", + "Properties": { + "ContainerDefinitions": [ + { + "Cpu": 256, + "Environment": [ + { + "Name": "PORT", + "Value": "80" + }, + { + "Name": "URL", + "Value": "http://name" + } + ], + "Essential": true, + "Image": "nathanpeck/greeter", + "Memory": 512, + "Name": "app", + "PortMappings": [ + { + "ContainerPort": 80, + "Protocol": "tcp" + } + ], + "Ulimits": [ + { + "HardLimit": 1024000, + "Name": "nofile", + "SoftLimit": 1024000 + } + ] + } + ], + "Cpu": "256", + "Family": "awsecsintegClientServicetaskdefinition11AA48A8", + "Memory": "512", + "NetworkMode": "awsvpc", + "RequiresCompatibilities": [ + "EC2", + "FARGATE" + ], + "TaskRoleArn": { + "Fn::GetAtt": [ + "ClientServicetaskdefinitionTaskRoleACC19FE3", + "Arn" + ] + } } + }, + "ClientServiceserviceService8F3E675C": { + "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": [ + "ClientServiceserviceSecurityGroupFBB92787", + "GroupId" + ] + } + ], + "Subnets": [ + { + "Ref": "productionenvironmentvpcPrivateSubnet1Subnet53F632E6" + }, + { + "Ref": "productionenvironmentvpcPrivateSubnet2Subnet756FB93C" + } + ] + } + }, + "ServiceConnectConfiguration": { + "Enabled": true, + "Namespace": "production" + }, + "TaskDefinition": { + "Ref": "ClientServicetaskdefinition89718CF4" + } + }, + "DependsOn": [ + "productionenvironmentclusterDefaultServiceDiscoveryNamespaceBE74D64D" + ] + }, + "ClientServiceserviceSecurityGroupFBB92787": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-ecs-integ/ClientService-service/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "VpcId": { + "Ref": "productionenvironmentvpcAEB47DF7" + } + }, + "DependsOn": [ + "productionenvironmentclusterDefaultServiceDiscoveryNamespaceBE74D64D" + ] } }, "Parameters": { diff --git a/test/aliased-port.integ.ts b/test/aliased-port.integ.ts index 3a9b91b4..110ebd9f 100644 --- a/test/aliased-port.integ.ts +++ b/test/aliased-port.integ.ts @@ -28,7 +28,27 @@ aliasedPortServiceDescription.add(new AliasedPortExtension({ })); new Service(stack, 'ServiceConnect', { - environment: environment, + environment, serviceDescription: aliasedPortServiceDescription, desiredCount: 1, }); + +const otherDescription = new ServiceDescription(); + +otherDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/greeter'), + environment: { + PORT: '80', + URL: 'http://name', + }, +})); + +new Service(stack, 'ClientService', { + environment, + serviceDescription: otherDescription, + desiredCount: 1, + enableServiceConnect: true, +}); diff --git a/test/aliased-port.test.ts b/test/aliased-port.test.ts index af6b111c..d2b7ac22 100644 --- a/test/aliased-port.test.ts +++ b/test/aliased-port.test.ts @@ -117,4 +117,65 @@ describe('aliased port', () => { }, }); }); + + test('when enabling service connect on a client service', () => { + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + + environment.addDefaultCloudMapNamespace({ + name: environment.id, + }); + + const svc = new Service(stack, 'my-service', { + environment, + serviceDescription, + }); + svc.enableServiceConnect(); + + Template.fromStack(stack).hasResourceProperties('AWS::ECS::Service', { + ServiceConnectConfiguration: { + Enabled: true, + Namespace: 'production', + }, + }); + }); + + test('cannot enable service connect on a cluster with no namespace', () => { + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + const svc = new Service(stack, 'my-service', { + environment, + serviceDescription, + }); + + expect(() => { + svc.enableServiceConnect(); + }).toThrow('Environment must have a default CloudMap namespace to enable Service Connect.'); + }); + + test('cannot add two Aliased Port extensions', () => { + serviceDescription.add(new Container({ + cpu: 256, + memoryMiB: 512, + trafficPort: 80, + image: ecs.ContainerImage.fromRegistry('nathanpeck/name'), + })); + serviceDescription.add(new AliasedPortExtension({ + alias: 'name', + })); + expect(() => { + serviceDescription.add(new AliasedPortExtension({ + alias: 'name2', + aliasPort: 8080, + })); + }).toThrow('An extension called aliasedPort has already been added'); + }); }); \ No newline at end of file