Skip to content

Commit 3d192a9

Browse files
authored
feat(ecs): add BaseService.fromServiceArnWithCluster() for use in CodePipeline (#18530)
This adds support for importing a ECS Cluster via the Arn, and not requiring the VPC or Security Groups. This will generate an ICluster which can be used in `Ec2Service.fromEc2ServiceAttributes()` and `FargateService.fromFargateServiceAttributes()` to get an `IBaseService` which can be used in the `EcsDeployAction` to allow for cross account/region deployments in CodePipelines. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent de3fa57 commit 3d192a9

File tree

7 files changed

+266
-3
lines changed

7 files changed

+266
-3
lines changed

packages/@aws-cdk/aws-codepipeline-actions/README.md

+33
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,39 @@ const deployStage = pipeline.addStage({
764764

765765
[image definition file]: https://docs.aws.amazon.com/codepipeline/latest/userguide/pipelines-create.html#pipelines-create-image-definitions
766766

767+
#### Deploying ECS applications to existing services
768+
769+
CodePipeline can deploy to an existing ECS service which uses the
770+
[ECS service ARN format that contains the Cluster name](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-account-settings.html#ecs-resource-ids).
771+
This also works if the service is in a different account and/or region than the pipeline:
772+
773+
```ts
774+
import * as ecs from '@aws-cdk/aws-ecs';
775+
776+
const service = ecs.BaseService.fromServiceArnWithCluster(this, 'EcsService',
777+
'arn:aws:ecs:us-east-1:123456789012:service/myClusterName/myServiceName'
778+
);
779+
const pipeline = new codepipeline.Pipeline(this, 'MyPipeline');
780+
const buildOutput = new codepipeline.Artifact();
781+
// add source and build stages to the pipeline as usual...
782+
const deployStage = pipeline.addStage({
783+
stageName: 'Deploy',
784+
actions: [
785+
new codepipeline_actions.EcsDeployAction({
786+
actionName: 'DeployAction',
787+
service: service,
788+
input: buildOutput,
789+
}),
790+
],
791+
});
792+
```
793+
794+
When deploying across accounts, especially in a CDK Pipelines self-mutating pipeline,
795+
it is recommended to provide the `role` property to the `EcsDeployAction`.
796+
The Role will need to have permissions assigned to it for ECS deployment.
797+
See [the CodePipeline documentation](https://docs.aws.amazon.com/codepipeline/latest/userguide/how-to-custom-role.html#how-to-update-role-new-services)
798+
for the permissions needed.
799+
767800
#### Deploying ECS applications stored in a separate source code repository
768801

769802
The idiomatic CDK way of deploying an ECS application is to have your Dockerfiles and your CDK code in the same source code repository,

packages/@aws-cdk/aws-codepipeline-actions/test/ecs/ecs-deploy-action.test.ts

+78
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,84 @@ describe('ecs deploy action', () => {
196196

197197

198198
});
199+
200+
test('can be created by existing service with cluster ARN format', () => {
201+
const app = new cdk.App();
202+
const stack = new cdk.Stack(app, 'PipelineStack', {
203+
env: {
204+
region: 'pipeline-region', account: 'pipeline-account',
205+
},
206+
});
207+
const clusterName = 'cluster-name';
208+
const serviceName = 'service-name';
209+
const region = 'service-region';
210+
const account = 'service-account';
211+
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/${serviceName}`;
212+
const service = ecs.BaseService.fromServiceArnWithCluster(stack, 'FargateService', serviceArn);
213+
214+
const artifact = new codepipeline.Artifact('Artifact');
215+
const bucket = new s3.Bucket(stack, 'PipelineBucket', {
216+
versioned: true,
217+
removalPolicy: cdk.RemovalPolicy.DESTROY,
218+
});
219+
const source = new cpactions.S3SourceAction({
220+
actionName: 'Source',
221+
output: artifact,
222+
bucket,
223+
bucketKey: 'key',
224+
});
225+
const action = new cpactions.EcsDeployAction({
226+
actionName: 'ECS',
227+
service: service,
228+
input: artifact,
229+
});
230+
new codepipeline.Pipeline(stack, 'Pipeline', {
231+
stages: [
232+
{
233+
stageName: 'Source',
234+
actions: [source],
235+
},
236+
{
237+
stageName: 'Deploy',
238+
actions: [action],
239+
},
240+
],
241+
});
242+
243+
Template.fromStack(stack).hasResourceProperties('AWS::CodePipeline::Pipeline', {
244+
Stages: [
245+
{},
246+
{
247+
Actions: [
248+
{
249+
Name: 'ECS',
250+
ActionTypeId: {
251+
Category: 'Deploy',
252+
Provider: 'ECS',
253+
},
254+
Configuration: {
255+
ClusterName: clusterName,
256+
ServiceName: serviceName,
257+
},
258+
Region: region,
259+
RoleArn: {
260+
'Fn::Join': [
261+
'',
262+
[
263+
'arn:',
264+
{
265+
Ref: 'AWS::Partition',
266+
},
267+
`:iam::${account}:role/pipelinestack-support-serloyecsactionrole49867f847238c85af7c0`,
268+
],
269+
],
270+
},
271+
},
272+
],
273+
},
274+
],
275+
});
276+
});
199277
});
200278
});
201279

packages/@aws-cdk/aws-ecs/README.md

+9
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ const cluster = new ecs.Cluster(this, 'Cluster', {
9696
});
9797
```
9898

99+
The following code imports an existing cluster using the ARN which can be used to
100+
import an Amazon ECS service either EC2 or Fargate.
101+
102+
```ts
103+
const clusterArn = 'arn:aws:ecs:us-east-1:012345678910:cluster/clusterName';
104+
105+
const cluster = ecs.Cluster.fromClusterArn(this, 'Cluster', clusterArn);
106+
```
107+
99108
To use tasks with Amazon EC2 launch-type, you have to add capacity to
100109
the cluster in order for tasks to be scheduled on your instances. Typically,
101110
you add an AutoScalingGroup with instances running the latest

packages/@aws-cdk/aws-ecs/lib/base/base-service.ts

+42-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import * as elb from '@aws-cdk/aws-elasticloadbalancing';
55
import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2';
66
import * as iam from '@aws-cdk/aws-iam';
77
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
8-
import { Annotations, Duration, IResolvable, IResource, Lazy, Resource, Stack } from '@aws-cdk/core';
8+
import { Annotations, Duration, IResolvable, IResource, Lazy, Resource, Stack, ArnFormat } from '@aws-cdk/core';
99
import { Construct } from 'constructs';
1010
import { LoadBalancerTargetOptions, NetworkMode, TaskDefinition } from '../base/task-definition';
11-
import { ICluster, CapacityProviderStrategy, ExecuteCommandLogging } from '../cluster';
11+
import { ICluster, CapacityProviderStrategy, ExecuteCommandLogging, Cluster } from '../cluster';
1212
import { ContainerDefinition, Protocol } from '../container-definition';
1313
import { CfnService } from '../ecs.generated';
1414
import { ScalableTaskCount } from './scalable-task-count';
@@ -315,6 +315,46 @@ export interface IBaseService extends IService {
315315
*/
316316
export abstract class BaseService extends Resource
317317
implements IBaseService, elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget, elb.ILoadBalancerTarget {
318+
/**
319+
* Import an existing ECS/Fargate Service using the service cluster format.
320+
* The format is the "new" format "arn:aws:ecs:region:aws_account_id:service/cluster-name/service-name".
321+
* @see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-account-settings.html#ecs-resource-ids
322+
*/
323+
public static fromServiceArnWithCluster(scope: Construct, id: string, serviceArn: string): IBaseService {
324+
const stack = Stack.of(scope);
325+
const arn = stack.splitArn(serviceArn, ArnFormat.SLASH_RESOURCE_NAME);
326+
const resourceName = arn.resourceName;
327+
if (!resourceName) {
328+
throw new Error('Missing resource Name from service ARN: ${serviceArn}');
329+
}
330+
const resourceNameParts = resourceName.split('/');
331+
if (resourceNameParts.length !== 2) {
332+
throw new Error(`resource name ${resourceName} from service ARN: ${serviceArn} is not using the ARN cluster format`);
333+
}
334+
const clusterName = resourceNameParts[0];
335+
const serviceName = resourceNameParts[1];
336+
337+
const clusterArn = Stack.of(scope).formatArn({
338+
partition: arn.partition,
339+
region: arn.region,
340+
account: arn.account,
341+
service: 'ecs',
342+
resource: 'cluster',
343+
resourceName: clusterName,
344+
});
345+
346+
const cluster = Cluster.fromClusterArn(scope, `${id}Cluster`, clusterArn);
347+
348+
class Import extends Resource implements IBaseService {
349+
public readonly serviceArn = serviceArn;
350+
public readonly serviceName = serviceName;
351+
public readonly cluster = cluster;
352+
}
353+
354+
return new Import(scope, id, {
355+
environmentFromArn: serviceArn,
356+
});
357+
}
318358

319359
/**
320360
* The security groups which manage the allowed network traffic for the service.

packages/@aws-cdk/aws-ecs/lib/cluster.ts

+36-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import * as kms from '@aws-cdk/aws-kms';
66
import * as logs from '@aws-cdk/aws-logs';
77
import * as s3 from '@aws-cdk/aws-s3';
88
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
9-
import { Duration, Lazy, IResource, Resource, Stack, Aspects, IAspect, IConstruct } from '@aws-cdk/core';
9+
import { Duration, Lazy, IResource, Resource, Stack, Aspects, IAspect, IConstruct, ArnFormat } from '@aws-cdk/core';
1010
import { Construct } from 'constructs';
1111
import { BottleRocketImage, EcsOptimizedAmi } from './amis';
1212
import { InstanceDrainHook } from './drain-hook/instance-drain-hook';
@@ -105,6 +105,41 @@ export class Cluster extends Resource implements ICluster {
105105
return new ImportedCluster(scope, id, attrs);
106106
}
107107

108+
/**
109+
* Import an existing cluster to the stack from the cluster ARN.
110+
* This does not provide access to the vpc, hasEc2Capacity, or connections -
111+
* use the `fromClusterAttributes` method to access those properties.
112+
*/
113+
public static fromClusterArn(scope: Construct, id: string, clusterArn: string): ICluster {
114+
const stack = Stack.of(scope);
115+
const arn = stack.splitArn(clusterArn, ArnFormat.SLASH_RESOURCE_NAME);
116+
const clusterName = arn.resourceName;
117+
118+
if (!clusterName) {
119+
throw new Error(`Missing required Cluster Name from Cluster ARN: ${clusterArn}`);
120+
}
121+
122+
const errorSuffix = 'is not available for a Cluster imported using fromClusterArn(), please use fromClusterAttributes() instead.';
123+
124+
class Import extends Resource implements ICluster {
125+
public readonly clusterArn = clusterArn;
126+
public readonly clusterName = clusterName!;
127+
get hasEc2Capacity(): boolean {
128+
throw new Error(`hasEc2Capacity ${errorSuffix}`);
129+
}
130+
get connections(): ec2.Connections {
131+
throw new Error(`connections ${errorSuffix}`);
132+
}
133+
get vpc(): ec2.IVpc {
134+
throw new Error(`vpc ${errorSuffix}`);
135+
}
136+
}
137+
138+
return new Import(scope, id, {
139+
environmentFromArn: clusterArn,
140+
});
141+
}
142+
108143
/**
109144
* Manage the allowed network connections for the cluster with Security Groups.
110145
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as cdk from '@aws-cdk/core';
2+
import * as ecs from '../lib';
3+
4+
let stack: cdk.Stack;
5+
6+
beforeEach(() => {
7+
stack = new cdk.Stack();
8+
});
9+
10+
describe('When import an ECS Service', () => {
11+
test('with serviceArnWithCluster', () => {
12+
// GIVEN
13+
const clusterName = 'cluster-name';
14+
const serviceName = 'my-http-service';
15+
const region = 'service-region';
16+
const account = 'service-account';
17+
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/${serviceName}`;
18+
19+
// WHEN
20+
const service = ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', serviceArn);
21+
22+
// THEN
23+
expect(service.serviceArn).toEqual(serviceArn);
24+
expect(service.serviceName).toEqual(serviceName);
25+
expect(service.env.account).toEqual(account);
26+
expect(service.env.region).toEqual(region);
27+
28+
expect(service.cluster.clusterName).toEqual(clusterName);
29+
expect(service.cluster.env.account).toEqual(account);
30+
expect(service.cluster.env.region).toEqual(region);
31+
});
32+
33+
test('throws an expection if no resourceName provided on fromServiceArnWithCluster', () => {
34+
expect(() => {
35+
ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', 'arn:aws:ecs:service-region:service-account:service');
36+
}).toThrowError(/Missing resource Name from service ARN/);
37+
});
38+
39+
test('throws an expection if not using cluster arn format on fromServiceArnWithCluster', () => {
40+
expect(() => {
41+
ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', 'arn:aws:ecs:service-region:service-account:service/my-http-service');
42+
}).toThrowError(/is not using the ARN cluster format/);
43+
});
44+
});

packages/@aws-cdk/aws-ecs/test/cluster.test.ts

+24
Original file line numberDiff line numberDiff line change
@@ -2140,6 +2140,30 @@ describe('cluster', () => {
21402140

21412141

21422142
});
2143+
2144+
test('When importing ECS Cluster via Arn', () => {
2145+
// GIVEN
2146+
const stack = new cdk.Stack();
2147+
const clusterName = 'my-cluster';
2148+
const region = 'service-region';
2149+
const account = 'service-account';
2150+
const cluster = ecs.Cluster.fromClusterArn(stack, 'Cluster', `arn:aws:ecs:${region}:${account}:cluster/${clusterName}`);
2151+
2152+
// THEN
2153+
expect(cluster.clusterName).toEqual(clusterName);
2154+
expect(cluster.env.region).toEqual(region);
2155+
expect(cluster.env.account).toEqual(account);
2156+
});
2157+
2158+
test('throws error when import ECS Cluster without resource name in arn', () => {
2159+
// GIVEN
2160+
const stack = new cdk.Stack();
2161+
2162+
// THEN
2163+
expect(() => {
2164+
ecs.Cluster.fromClusterArn(stack, 'Cluster', 'arn:aws:ecs:service-region:service-account:cluster');
2165+
}).toThrowError(/Missing required Cluster Name from Cluster ARN: /);
2166+
});
21432167
});
21442168

21452169
test('can add ASG capacity via Capacity Provider by not specifying machineImageType', () => {

0 commit comments

Comments
 (0)