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