From 7c6c78b67bc141de5ef8f19c8e3e22bab711846a Mon Sep 17 00:00:00 2001 From: Toby Tipton Date: Wed, 8 Dec 2021 13:30:42 -0500 Subject: [PATCH] support serviceAttributes being used instead of requiring ecs service --- .../lib/ecs/deploy-action.ts | 56 ++- .../test/ecs/ecs-deploy-action.test.ts | 426 ++++++++++++++++++ 2 files changed, 479 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/aws-codepipeline-actions/lib/ecs/deploy-action.ts b/packages/@aws-cdk/aws-codepipeline-actions/lib/ecs/deploy-action.ts index 22c9988ac3ff5..6a829669ab932 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/lib/ecs/deploy-action.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/lib/ecs/deploy-action.ts @@ -9,6 +9,20 @@ import { deployArtifactBounds } from '../common'; // eslint-disable-next-line no-duplicate-imports, import/order import { Construct } from '@aws-cdk/core'; +/** + * ECS Service Attributes to describle the CodePipeline Actions. + */ +export interface EcsServiceAttribute { + /** + * The ECS serviceName to deploy to. + */ + readonly serviceName: string; + /** + * The ECS clusterName to deploy to. + */ + readonly clusterName: string; +} + /** * Construction properties of {@link EcsDeployAction}. */ @@ -42,9 +56,23 @@ export interface EcsDeployActionProps extends codepipeline.CommonAwsActionProps /** * The ECS Service to deploy. + * @default - one of this property, or `serviceAttributes`, is required + */ + readonly service?: ecs.IBaseService; + + /** + * The ECS Service Attributes to deploy, must be specificed with `region` and `role`. + * Ensure if the role being specified is not in the same account has ECS deployment permissions. + * @default - one of this property, or `service`, is required + * @see https://docs.aws.amazon.com/codepipeline/latest/userguide/how-to-custom-role.html#how-to-update-role-new-services */ - readonly service: ecs.IBaseService; + readonly serviceAttributes?: EcsServiceAttribute; + /** + * The region the ECS Service is deployed in, must be specificed when using `serviceAttributes`. + * @default - will be defined by `service`. + */ + readonly region?: string; /** * Timeout for the ECS deployment in minutes. Value must be between 1-60. * @@ -71,6 +99,27 @@ export class EcsDeployAction extends Action { resource: props.service, }); + if (props.service && props.serviceAttributes) { + throw new Error("Exactly one of 'service' or 'serviceAttributes' can be provided in the ECS deploy Action"); + } + if (!props.service && !props.serviceAttributes) { + throw new Error("Specifying one of 'service' or 'serviceAttributes' is required for the ECS deploy Action"); + } + if (props.serviceAttributes) { + if (!props.region && !props.role) { + throw new Error("Specifying 'region' and 'role' is required when specifying 'serviceAttributes'"); + } + if (!props.region) { + throw new Error("Specifying 'region' is required when specifying 'serviceAttributes'"); + } + if (!props.role) { + throw new Error("Specifying 'role' is required when specifying 'serviceAttributes'"); + } + } + if (props.service && props.region) { + throw new Error("Must not specify 'region' when specifying 'service'"); + } + const deploymentTimeout = props.deploymentTimeout?.toMinutes({ integral: true }); if (deploymentTimeout !== undefined && (deploymentTimeout < 1 || deploymentTimeout > 60)) { throw new Error(`Deployment timeout must be between 1 and 60 minutes, got: ${deploymentTimeout}`); @@ -84,6 +133,7 @@ export class EcsDeployAction extends Action { codepipeline.ActionConfig { // permissions based on CodePipeline documentation: // https://docs.aws.amazon.com/codepipeline/latest/userguide/how-to-custom-role.html#how-to-update-role-new-services + // If this role is not in the same account, this policy will not be applied. options.role.addToPolicy(new iam.PolicyStatement({ actions: [ 'ecs:DescribeServices', @@ -113,8 +163,8 @@ export class EcsDeployAction extends Action { return { configuration: { - ClusterName: this.props.service.cluster.clusterName, - ServiceName: this.props.service.serviceName, + ClusterName: this.props.serviceAttributes ? this.props.serviceAttributes!.clusterName : this.props.service!.cluster.clusterName, + ServiceName: this.props.serviceAttributes ? this.props.serviceAttributes!.serviceName: this.props.service!.serviceName, FileName: this.props.imageFile?.fileName, DeploymentTimeout: this.deploymentTimeout, }, diff --git a/packages/@aws-cdk/aws-codepipeline-actions/test/ecs/ecs-deploy-action.test.ts b/packages/@aws-cdk/aws-codepipeline-actions/test/ecs/ecs-deploy-action.test.ts index 63927d5832ec8..5f65abdb97c5c 100644 --- a/packages/@aws-cdk/aws-codepipeline-actions/test/ecs/ecs-deploy-action.test.ts +++ b/packages/@aws-cdk/aws-codepipeline-actions/test/ecs/ecs-deploy-action.test.ts @@ -2,12 +2,149 @@ import '@aws-cdk/assert-internal/jest'; import * as codepipeline from '@aws-cdk/aws-codepipeline'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; +import * as iam from '@aws-cdk/aws-iam'; import * as s3 from '@aws-cdk/aws-s3'; import * as cdk from '@aws-cdk/core'; import * as cpactions from '../../lib'; +// keep this import separate from other imports to reduce chance for merge conflicts with v2-main +// eslint-disable-next-line no-duplicate-imports, import/order +import { Construct } from '@aws-cdk/core'; + describe('ecs deploy action', () => { describe('ECS deploy Action', () => { + test('throws an exception if neither service nor serviceAttributes were provided', () => { + const artifact = new codepipeline.Artifact('Artifact'); + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + input: artifact, + }); + }).toThrow(/one of 'service' or 'serviceAttributes' is required/); + + + }); + + test('can be created just by specifying the service', () => { + const service = anyEcsService(); + const artifact = new codepipeline.Artifact('Artifact'); + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + service, + input: artifact, + }); + }).not.toThrow(); + + + }); + + test('throws an exception if specifiying the serviceAttributes were provided without region and role', () => { + const artifact = new codepipeline.Artifact('Artifact'); + const serviceAttributes = { + clusterName: 'cluster-name', + serviceName: 'service-name', + }; + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + input: artifact, + serviceAttributes: serviceAttributes, + }); + }).toThrow(/'region' and 'role' is required when specifying 'serviceAttributes'/); + }); + + test('throws an exception if specifiying the serviceAttributes were provided without region', () => { + const artifact = new codepipeline.Artifact('Artifact'); + const serviceAttributes = { + clusterName: 'cluster-name', + serviceName: 'service-name', + }; + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + input: artifact, + serviceAttributes: serviceAttributes, + role: anyIamRole(), + }); + }).toThrow(/'region' is required when specifying 'serviceAttributes'/); + }); + + test('throws an exception if specifiying the serviceAttributes were provided without role', () => { + const artifact = new codepipeline.Artifact('Artifact'); + const serviceAttributes = { + clusterName: 'cluster-name', + serviceName: 'service-name', + }; + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + input: artifact, + serviceAttributes: serviceAttributes, + region: 'us-east-1', + }); + }).toThrow(/'role' is required when specifying 'serviceAttributes'/); + }); + + test('can be created just by specifying the serviceAttributes with role and region', () => { + const artifact = new codepipeline.Artifact('Artifact'); + const serviceAttributes = { + clusterName: 'cluster-name', + serviceName: 'service-name', + }; + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + serviceAttributes: serviceAttributes, + input: artifact, + region: 'us-east-1', + role: anyIamRole(), + }); + }).not.toThrow(); + + }); + + test('throws an exception if both service and serviceAttributes were provided', () => { + const service = anyEcsService(); + const artifact = new codepipeline.Artifact('Artifact'); + const serviceAttributes = { + clusterName: 'cluster-name', + serviceName: 'service-name', + }; + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + service, + input: artifact, + serviceAttributes: serviceAttributes, + }); + }).toThrow(/one of 'service' or 'serviceAttributes' can be provided/); + + }); + + test('throws an exception if both service and region were provided', () => { + const service = anyEcsService(); + const artifact = new codepipeline.Artifact('Artifact'); + + + expect(() => { + new cpactions.EcsDeployAction({ + actionName: 'ECS', + service, + input: artifact, + region: 'us-east-1', + }); + }).toThrow(/not specify 'region' when specifying 'service'/); + + }); + test('throws an exception if neither inputArtifact nor imageFile were provided', () => { const service = anyEcsService(); @@ -193,7 +330,282 @@ describe('ecs deploy action', () => { }, ], }); + }); + test('throws an error when service imported is defined in a pipeline stage', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'PipelineStack', { + env: { + region: 'us-east-1', + }, + }); + 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 pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [source], + }, + ], + }); + + class TestStack extends cdk.Stack { + public readonly service: ecs.IBaseService; + // eslint-disable-next-line @aws-cdk/no-core-construct + constructor(scope: Construct, id: string, props: cdk.StackProps) { + super(scope, id, props); + const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition'); + taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + }); + const vpc = new ec2.Vpc(this, 'VPC'); + const cluster = new ecs.Cluster(this, 'Cluster', { + vpc, + }); + this.service = new ecs.FargateService(this, 'FargateService', { + cluster, + taskDefinition, + }); + } + } + class TestStage extends cdk.Stage { + public readonly service: ecs.IBaseService; + // eslint-disable-next-line @aws-cdk/no-core-construct + constructor(scope: Construct, id: string, props: cdk.StageProps) { + super(scope, id, props); + const testStack = new TestStack(this, 'ecsStack', {}); + this.service = testStack.service; + } + } + const testStage = new TestStage( + stack, + 'TestStage', + { + env: { + region: 'us-east-1', + }, + }, + ); + const testIStage = pipeline.addStage(testStage); + const deployAction = new cpactions.EcsDeployAction({ + actionName: 'ECS', + service: testStage.service, + imageFile: artifact.atPath('imageFile.json'), + }); + testIStage.addAction(deployAction); + expect(() => { + app.synth(); + }).toThrow(/dependency cannot cross stage boundaries/); + }); + test('can be created by serviceAttribes, region and role', () => { + const app = new cdk.App(); + const stack = new cdk.Stack( + app, + 'pipelineStack', + { + env: { + region: 'us-east-1', + }, + }, + ); + 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 serviceAttributes = { + clusterName: 'cluster-name', + serviceName: 'service-name', + }; + const region = 'us-east-1'; + const deploymentRole = new iam.Role(stack, 'DeploymentRole', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + roleName: cdk.PhysicalName.GENERATE_IF_NEEDED, + }); + const role = iam.Role.fromRoleArn( + stack, + 'roleFromArnForIRole', + deploymentRole.roleArn, + ); + const action = new cpactions.EcsDeployAction({ + actionName: 'ECS', + serviceAttributes: serviceAttributes, + imageFile: artifact.atPath('imageFile.json'), + region: region, + role: role, + + }); + new codepipeline.Pipeline(stack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [source], + }, + { + stageName: 'Deploy', + actions: [action], + }, + ], + }); + + expect(stack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: [ + {}, + { + Actions: [ + { + Name: 'ECS', + ActionTypeId: { + Category: 'Deploy', + Provider: 'ECS', + }, + Configuration: { + ClusterName: 'cluster-name', + ServiceName: 'service-name', + FileName: 'imageFile.json', + }, + Region: 'us-east-1', + }, + ], + }, + ], + }); + }); + test('can be created cross stage serviceAttribes, region and role', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'PipelineStack', { + env: { + region: 'us-east-1', + }, + }); + 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 deploymentRole = new iam.Role(stack, 'DeploymentRole', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + roleName: cdk.PhysicalName.GENERATE_IF_NEEDED, + }); + const role = iam.Role.fromRoleArn( + stack, + 'roleFromArnForIRole', + deploymentRole.roleArn, + ); + const pipeline = new codepipeline.Pipeline(stack, 'Pipeline', { + stages: [ + { + stageName: 'Source', + actions: [source], + }, + ], + }); + + interface TestStackProps extends cdk.StackProps { + readonly serviceName: string; + readonly clusterName: string; + } + + class TestStack extends cdk.Stack { + // eslint-disable-next-line @aws-cdk/no-core-construct + constructor(scope: Construct, id: string, props: TestStackProps) { + super(scope, id, props); + const taskDefinition = new ecs.FargateTaskDefinition(this, 'TaskDefinition'); + taskDefinition.addContainer('MainContainer', { + image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'), + }); + const vpc = new ec2.Vpc(this, 'VPC'); + const cluster = new ecs.Cluster(this, 'Cluster', { + clusterName: props.clusterName, + vpc, + }); + new ecs.FargateService(this, 'FargateService', { + serviceName: props.serviceName, + cluster, + taskDefinition, + }); + } + } + class TestStage extends cdk.Stage { + public readonly serviceName: string; + public readonly clusterName: string; + // eslint-disable-next-line @aws-cdk/no-core-construct + constructor(scope: Construct, id: string, props: cdk.StageProps) { + super(scope, id, props); + this.serviceName = 'service-name'; + this.clusterName = 'cluster-name'; + new TestStack(this, 'ecsStack', { + serviceName: this.serviceName, + clusterName: this.clusterName, + }); + } + } + const testStage = new TestStage( + stack, + 'TestStage', + { + env: { + region: 'us-east-1', + }, + }, + ); + const testIStage = pipeline.addStage(testStage); + const deployAction = new cpactions.EcsDeployAction({ + actionName: 'ECS', + serviceAttributes: { + serviceName: testStage.serviceName, + clusterName: testStage.clusterName, + }, + region: 'us-east-1', + role: role, + imageFile: artifact.atPath('imageFile.json'), + }); + testIStage.addAction(deployAction); + app.synth(); + expect(stack).toHaveResourceLike('AWS::CodePipeline::Pipeline', { + Stages: [ + {}, + { + Actions: [ + { + Name: 'ECS', + ActionTypeId: { + Category: 'Deploy', + Provider: 'ECS', + }, + Configuration: { + ClusterName: 'cluster-name', + ServiceName: 'service-name', + FileName: 'imageFile.json', + }, + Region: 'us-east-1', + }, + ], + }, + ], + }); }); }); @@ -214,3 +626,17 @@ function anyEcsService(): ecs.FargateService { taskDefinition, }); } + + +function anyIamRole(): iam.IRole { + const stack = new cdk.Stack(); + const role = new iam.Role(stack, 'DeploymentRole', { + assumedBy: new iam.ServicePrincipal('codepipeline.amazonaws.com'), + roleName: cdk.PhysicalName.GENERATE_IF_NEEDED, + }); + return iam.Role.fromRoleArn( + stack, + 'roleFromArn', + role.roleArn, + ); +}