Skip to content

Commit

Permalink
feat(ecs): add BaseService.fromServiceArnWithCluster() for use in C…
Browse files Browse the repository at this point in the history
…odePipeline (#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*
  • Loading branch information
tobytipton authored Jan 22, 2022
1 parent de3fa57 commit 3d192a9
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 3 deletions.
33 changes: 33 additions & 0 deletions packages/@aws-cdk/aws-codepipeline-actions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,39 @@ const deployStage = pipeline.addStage({

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

#### Deploying ECS applications to existing services

CodePipeline can deploy to an existing ECS service which uses the
[ECS service ARN format that contains the Cluster name](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-account-settings.html#ecs-resource-ids).
This also works if the service is in a different account and/or region than the pipeline:

```ts
import * as ecs from '@aws-cdk/aws-ecs';

const service = ecs.BaseService.fromServiceArnWithCluster(this, 'EcsService',
'arn:aws:ecs:us-east-1:123456789012:service/myClusterName/myServiceName'
);
const pipeline = new codepipeline.Pipeline(this, 'MyPipeline');
const buildOutput = new codepipeline.Artifact();
// add source and build stages to the pipeline as usual...
const deployStage = pipeline.addStage({
stageName: 'Deploy',
actions: [
new codepipeline_actions.EcsDeployAction({
actionName: 'DeployAction',
service: service,
input: buildOutput,
}),
],
});
```

When deploying across accounts, especially in a CDK Pipelines self-mutating pipeline,
it is recommended to provide the `role` property to the `EcsDeployAction`.
The Role will need to have permissions assigned to it for ECS deployment.
See [the CodePipeline documentation](https://docs.aws.amazon.com/codepipeline/latest/userguide/how-to-custom-role.html#how-to-update-role-new-services)
for the permissions needed.

#### Deploying ECS applications stored in a separate source code repository

The idiomatic CDK way of deploying an ECS application is to have your Dockerfiles and your CDK code in the same source code repository,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,84 @@ describe('ecs deploy action', () => {


});

test('can be created by existing service with cluster ARN format', () => {
const app = new cdk.App();
const stack = new cdk.Stack(app, 'PipelineStack', {
env: {
region: 'pipeline-region', account: 'pipeline-account',
},
});
const clusterName = 'cluster-name';
const serviceName = 'service-name';
const region = 'service-region';
const account = 'service-account';
const serviceArn = `arn:aws:ecs:${region}:${account}:service/${clusterName}/${serviceName}`;
const service = ecs.BaseService.fromServiceArnWithCluster(stack, 'FargateService', serviceArn);

const artifact = new codepipeline.Artifact('Artifact');
const bucket = new s3.Bucket(stack, 'PipelineBucket', {
versioned: true,
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
const source = new cpactions.S3SourceAction({
actionName: 'Source',
output: artifact,
bucket,
bucketKey: 'key',
});
const action = new cpactions.EcsDeployAction({
actionName: 'ECS',
service: service,
input: artifact,
});
new codepipeline.Pipeline(stack, 'Pipeline', {
stages: [
{
stageName: 'Source',
actions: [source],
},
{
stageName: 'Deploy',
actions: [action],
},
],
});

Template.fromStack(stack).hasResourceProperties('AWS::CodePipeline::Pipeline', {
Stages: [
{},
{
Actions: [
{
Name: 'ECS',
ActionTypeId: {
Category: 'Deploy',
Provider: 'ECS',
},
Configuration: {
ClusterName: clusterName,
ServiceName: serviceName,
},
Region: region,
RoleArn: {
'Fn::Join': [
'',
[
'arn:',
{
Ref: 'AWS::Partition',
},
`:iam::${account}:role/pipelinestack-support-serloyecsactionrole49867f847238c85af7c0`,
],
],
},
},
],
},
],
});
});
});
});

Expand Down
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);
```

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
44 changes: 42 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,46 @@ 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.
* The format is the "new" format "arn:aws:ecs:region:aws_account_id:service/cluster-name/service-name".
* @see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-account-settings.html#ecs-resource-ids
*/
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}');
}
const resourceNameParts = resourceName.split('/');
if (resourceNameParts.length !== 2) {
throw new Error(`resource name ${resourceName} from service ARN: ${serviceArn} is not using the ARN cluster format`);
}
const clusterName = resourceNameParts[0];
const serviceName = resourceNameParts[1];

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;
}

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

/**
* The security groups which manage the allowed network traffic for the service.
Expand Down
37 changes: 36 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,41 @@ export class Cluster extends Resource implements ICluster {
return new ImportedCluster(scope, id, attrs);
}

/**
* Import an existing cluster to the stack from the cluster ARN.
* This does not provide access to the vpc, hasEc2Capacity, or connections -
* use the `fromClusterAttributes` method to access those properties.
*/
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}`);
}
}

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

/**
* Manage the allowed network connections for the cluster with Security Groups.
*/
Expand Down
44 changes: 44 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,44 @@
import * as cdk from '@aws-cdk/core';
import * as ecs from '../lib';

let stack: cdk.Stack;

beforeEach(() => {
stack = new cdk.Stack();
});

describe('When import an ECS Service', () => {
test('with serviceArnWithCluster', () => {
// GIVEN
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', () => {
expect(() => {
ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', 'arn:aws:ecs:service-region:service-account:service');
}).toThrowError(/Missing resource Name from service ARN/);
});

test('throws an expection if not using cluster arn format on fromServiceArnWithCluster', () => {
expect(() => {
ecs.BaseService.fromServiceArnWithCluster(stack, 'Service', 'arn:aws:ecs:service-region:service-account:service/my-http-service');
}).toThrowError(/is not using the ARN cluster format/);
});
});
24 changes: 24 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,30 @@ 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 cluster = ecs.Cluster.fromClusterArn(stack, 'Cluster', `arn:aws:ecs:${region}:${account}:cluster/${clusterName}`);

// 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();

// THEN
expect(() => {
ecs.Cluster.fromClusterArn(stack, 'Cluster', 'arn:aws:ecs:service-region:service-account:cluster');
}).toThrowError(/Missing required Cluster Name from Cluster ARN: /);
});
});

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

0 comments on commit 3d192a9

Please sign in to comment.