Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ecs): add BaseService.fromServiceArnWithCluster() for use in CodePipeline #18530

Merged
merged 13 commits into from
Jan 22, 2022
Merged
9 changes: 9 additions & 0 deletions packages/@aws-cdk/aws-ecs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ const cluster = new ecs.Cluster(this, 'Cluster', {
});
```

The following code imports an existing cluster using the ARN which can be used to
import an Amazon ECS service either EC2 or Fargate.

```ts
const clusterArn = 'arn:aws:ecs:us-east-1:012345678910:cluster/clusterName';

const cluster = ecs.Cluster.fromClusterArn(this, 'Cluster', clusterArn);
```
skinny85 marked this conversation as resolved.
Show resolved Hide resolved

To use tasks with Amazon EC2 launch-type, you have to add capacity to
the cluster in order for tasks to be scheduled on your instances. Typically,
you add an AutoScalingGroup with instances running the latest
Expand Down
42 changes: 40 additions & 2 deletions packages/@aws-cdk/aws-ecs/lib/base/base-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import * as elb from '@aws-cdk/aws-elasticloadbalancing';
import * as elbv2 from '@aws-cdk/aws-elasticloadbalancingv2';
import * as iam from '@aws-cdk/aws-iam';
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
import { Annotations, Duration, IResolvable, IResource, Lazy, Resource, Stack } from '@aws-cdk/core';
import { Annotations, Duration, IResolvable, IResource, Lazy, Resource, Stack, ArnFormat } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { LoadBalancerTargetOptions, NetworkMode, TaskDefinition } from '../base/task-definition';
import { ICluster, CapacityProviderStrategy, ExecuteCommandLogging } from '../cluster';
import { ICluster, CapacityProviderStrategy, ExecuteCommandLogging, Cluster } from '../cluster';
import { ContainerDefinition, Protocol } from '../container-definition';
import { CfnService } from '../ecs.generated';
import { ScalableTaskCount } from './scalable-task-count';
Expand Down Expand Up @@ -315,6 +315,44 @@ export interface IBaseService extends IService {
*/
export abstract class BaseService extends Resource
implements IBaseService, elbv2.IApplicationLoadBalancerTarget, elbv2.INetworkLoadBalancerTarget, elb.ILoadBalancerTarget {
/**
* Import an existing ECS/Fargate Service using the service cluster format.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a note in the docs that the service ARN must be in the "new" format, with maybe a link to the AWS docs about it.

*/
public static fromServiceArnWithCluster(scope: Construct, id: string, serviceArn: string): IBaseService {
const stack = Stack.of(scope);
const arn = stack.splitArn(serviceArn, ArnFormat.SLASH_RESOURCE_NAME);
const resourceName = arn.resourceName;
if (!resourceName) {
throw new Error('Missing resource Name from service ARN: ${serviceArn}');
}

if (resourceName.split('/').length !== 2) {
throw new Error(`resource name ${resourceName} from service ARN: ${serviceArn} is not using the ARN cluster format`);
}
const clusterName = resourceName.split('/')[0];
const serviceName = resourceName.split('/')[1];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're repeating the resourceName.split('/') expression 3 times here.

Let's assign it to a local variable instead.


const clusterArn = Stack.of(scope).formatArn({
partition: arn.partition,
region: arn.region,
account: arn.account,
service: 'ecs',
resource: 'cluster',
resourceName: clusterName,
});

const cluster = Cluster.fromClusterArn(scope, `${id}Cluster`, clusterArn);

class Import extends Resource implements IBaseService {
public readonly serviceArn = serviceArn;
public readonly serviceName = serviceName;
public readonly cluster = cluster;

}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird formatting here:

Suggested change
public readonly cluster = cluster;
}
public readonly cluster = cluster;
}

return new Import(scope, id, {
environmentFromArn: serviceArn,
});
}

/**
* The security groups which manage the allowed network traffic for the service.
Expand Down
34 changes: 33 additions & 1 deletion packages/@aws-cdk/aws-ecs/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as kms from '@aws-cdk/aws-kms';
import * as logs from '@aws-cdk/aws-logs';
import * as s3 from '@aws-cdk/aws-s3';
import * as cloudmap from '@aws-cdk/aws-servicediscovery';
import { Duration, Lazy, IResource, Resource, Stack, Aspects, IAspect, IConstruct } from '@aws-cdk/core';
import { Duration, Lazy, IResource, Resource, Stack, Aspects, IAspect, IConstruct, ArnFormat } from '@aws-cdk/core';
import { Construct } from 'constructs';
import { BottleRocketImage, EcsOptimizedAmi } from './amis';
import { InstanceDrainHook } from './drain-hook/instance-drain-hook';
Expand Down Expand Up @@ -105,6 +105,38 @@ export class Cluster extends Resource implements ICluster {
return new ImportedCluster(scope, id, attrs);
}

/**
* Import an existing cluster to the stack from the cluster ARN.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a note in these docs that the Cluster returned does not allow accessing the vpc property.

*/
public static fromClusterArn(scope: Construct, id: string, clusterArn: string): ICluster {
const stack = Stack.of(scope);
const arn = stack.splitArn(clusterArn, ArnFormat.SLASH_RESOURCE_NAME);
const clusterName = arn.resourceName;

if (!clusterName) {
throw new Error(`Missing required Cluster Name from Cluster ARN: ${clusterArn}`);
}

const errorSuffix = 'is not available for a Cluster imported using fromClusterArn(), please use fromClusterAttributes() instead.';

class Import extends Resource implements ICluster {
public readonly clusterArn = clusterArn;
public readonly clusterName = clusterName!;
get hasEc2Capacity(): boolean {
throw new Error(`hasEc2Capacity ${errorSuffix}`);
}
get connections(): ec2.Connections {
throw new Error(`connections ${errorSuffix}`);
}
get vpc(): ec2.IVpc {
throw new Error(`vpc ${errorSuffix}`);
}
}
tobytipton marked this conversation as resolved.
Show resolved Hide resolved
return new Import(scope, id, {
environmentFromArn: clusterArn,
});
}

/**
* Manage the allowed network connections for the cluster with Security Groups.
*/
Expand Down
54 changes: 54 additions & 0 deletions packages/@aws-cdk/aws-ecs/test/base-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as cdk from '@aws-cdk/core';
import * as ecs from '../lib';

describe('When import an ECS Service', () => {
test('with serviceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're writing brand-new tests here, let's make them beautiful by extracting this const stack = new cdk.Stack(); line that is repeated in every test to a beforeEach() method.

const clusterName = 'cluster-name';
const serviceName = 'my-http-service';
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/${serviceName}`;

// WHEN
const service = ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', serviceArn);

// THEN
expect(service.serviceArn).toEqual(serviceArn);
expect(service.serviceName).toEqual(serviceName);
expect(service.env.account).toEqual(account);
expect(service.env.region).toEqual(region);

expect(service.cluster.clusterName).toEqual(clusterName);
expect(service.cluster.env.account).toEqual(account);
expect(service.cluster.env.region).toEqual(region);
});

test('throws an expection if no resourceName provided on fromServiceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's inline all of these variables (they are all used once).


//THEN
expect(() => {
ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', serviceArn);
}).toThrowError(/Missing resource Name from service ARN/);
});

test('throws an expection if not using cluster arn format on fromServiceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const region = 'service-region';
const account = 'service-account';
const serviceName = 'my-http-service';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${serviceName}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here - inline all of those variables (with the possible exception of serviceName, which is used twice, but I personally would inline even that one).


//THEN
expect(() => {
ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', serviceArn);
}).toThrowError(`resource name ${serviceName} from service ARN`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kind of a weird error message to assert here, but OK (I would probably write an assertion on the "is not using the ARN cluster format" part of the error message, seems more relevant to this test).

});
});
25 changes: 25 additions & 0 deletions packages/@aws-cdk/aws-ecs/test/cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2140,6 +2140,31 @@ describe('cluster', () => {


});

test('When importing ECS Cluster via Arn', () => {
// GIVEN
const stack = new cdk.Stack();
const clusterName = 'my-cluster';
const region = 'service-region';
const account = 'service-account';
const clusterArn = `arn:aws:ecs:${region}:${account}:cluster/${clusterName}`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would also inline this variable.

const cluster = ecs.Cluster.fromClusterArn(stack, 'Cluster', clusterArn);

// THEN
expect(cluster.clusterName).toEqual(clusterName);
expect(cluster.env.region).toEqual(region);
expect(cluster.env.account).toEqual(account);
});

test('throws error when import ECS Cluster without resource name in arn', () => {
// GIVEN
const stack = new cdk.Stack();
const clusterArn = 'arn:aws:ecs:service-region:service-account:cluster';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, I would probably inline this variable.

// THEN
expect(() => {
ecs.Cluster.fromClusterArn(stack, 'Cluster', clusterArn);
}).toThrowError(/Missing required Cluster Name from Cluster ARN: /);
});
});

test('can add ASG capacity via Capacity Provider by not specifying machineImageType', () => {
Expand Down
49 changes: 49 additions & 0 deletions packages/@aws-cdk/aws-ecs/test/ec2/ec2-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3333,5 +3333,54 @@ describe('ec2 service', () => {


});
test('with serviceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const clusterName = 'cluster-name';
const serviceName = 'my-http-service';
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/${serviceName}`;

// WHEN
const service = ecs.Ec2Service.fromServiceArnWithCluster(stack, 'EcsService', serviceArn);

// THEN
expect(service.serviceArn).toEqual(serviceArn);
expect(service.serviceName).toEqual(serviceName);
expect(service.env.account).toEqual(account);
expect(service.env.region).toEqual(region);

expect(service.cluster.clusterName).toEqual(clusterName);
expect(service.cluster.env.account).toEqual(account);
expect(service.cluster.env.region).toEqual(region);
});

test('throws an expection if no resourceName provided on fromServiceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service`;

//THEN
expect(() => {
ecs.Ec2Service.fromServiceArnWithCluster(stack, 'EcsService', serviceArn);
}).toThrowError(/Missing resource Name from service ARN/);
});

test('throws an expection if not using cluster arn format on fromServiceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const region = 'service-region';
const account = 'service-account';
const serviceName = 'my-http-service';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${serviceName}`;

//THEN
expect(() => {
ecs.Ec2Service.fromServiceArnWithCluster(stack, 'EcsService', serviceArn);
}).toThrowError(`resource name ${serviceName} from service ARN`);
});
});
});
50 changes: 50 additions & 0 deletions packages/@aws-cdk/aws-ecs/test/fargate/fargate-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2212,6 +2212,56 @@ describe('fargate service', () => {

});

test('with serviceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const clusterName = 'cluster-name';
const serviceName = 'my-http-service';
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/${serviceName}`;

// WHEN
const service = ecs.FargateService.fromServiceArnWithCluster(stack, 'FargateService', serviceArn);

// THEN
expect(service.serviceArn).toEqual(serviceArn);
expect(service.serviceName).toEqual(serviceName);
expect(service.env.account).toEqual(account);
expect(service.env.region).toEqual(region);

expect(service.cluster.clusterName).toEqual(clusterName);
expect(service.cluster.env.account).toEqual(account);
expect(service.cluster.env.region).toEqual(region);
});

test('throws an expection if no resourceName provided on fromServiceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service`;

//THEN
expect(() => {
ecs.FargateService.fromServiceArnWithCluster(stack, 'FargateService', serviceArn);
}).toThrowError(/Missing resource Name from service ARN/);
});

test('throws an expection if not using cluster arn format on fromServiceArnWithCluster', () => {
// GIVEN
const stack = new cdk.Stack();
const region = 'service-region';
const account = 'service-account';
const serviceName = 'my-http-service';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${serviceName}`;

//THEN
expect(() => {
ecs.FargateService.fromServiceArnWithCluster(stack, 'FargateService', serviceArn);
}).toThrowError(`resource name ${serviceName} from service ARN`);
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remove both of these new sets of tests. I think the BaseService tests are enough.

test('allows setting enable execute command', () => {
// GIVEN
const stack = new cdk.Stack();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think of adding a simple test in the @aws-cdk/aws-codepipeline-actions module for this? We already have a test file for the EcsDeployAction class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the test, did it for cross account and region.

Expand Down