From f08896143abe37882c82c1832b762f491fa5a450 Mon Sep 17 00:00:00 2001 From: Madeline Kusters <80541297+madeline-k@users.noreply.github.com> Date: Fri, 24 Dec 2021 06:33:36 -0800 Subject: [PATCH] feat(cli): hotswap deployments for CodeBuild projects (#18161) This extends the `cdk deploy --hotswap` command to support CodeBuild projects. This supports all changes to the `Source`, `SourceVersion`, and `Environment` attributes of the AWS::CodeBuild::Project cloudformation resource. The possible changes supported on the [Project](https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-codebuild.Project.html) L2 Construct will be changes to the [buildSpec](https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-codebuild.Project.html#buildspec), [environment](https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-codebuild.Project.html#environment), [environmentVariables](https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-codebuild.Project.html#environmentvariables), and [source](https://docs.aws.amazon.com/cdk/api/v1/docs/@aws-cdk_aws-codebuild.Project.html#source) constructor props. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- packages/aws-cdk/README.md | 1 + packages/aws-cdk/lib/api/aws-auth/sdk.ts | 5 + .../aws-cdk/lib/api/hotswap-deployments.ts | 2 + .../lib/api/hotswap/code-build-projects.ts | 67 ++ packages/aws-cdk/lib/api/hotswap/common.ts | 28 + .../aws-cdk/lib/api/hotswap/ecs-services.ts | 22 +- ...build-projects-hotswap-deployments.test.ts | 614 ++++++++++++++++++ .../test/api/hotswap/hotswap-test-setup.ts | 7 + packages/aws-cdk/test/util/mock-sdk.ts | 5 + 9 files changed, 731 insertions(+), 20 deletions(-) create mode 100644 packages/aws-cdk/lib/api/hotswap/code-build-projects.ts create mode 100644 packages/aws-cdk/test/api/hotswap/code-build-projects-hotswap-deployments.test.ts diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index faa5a0a121fe0..c89669d3c7e88 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -366,6 +366,7 @@ Hotswapping is currently supported for the following changes - Definition changes of AWS Step Functions State Machines. - Container asset changes of AWS ECS Services. - Website asset changes of AWS S3 Bucket Deployments. +- Source and Environment changes of AWS CodeBuild Projects. **⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments. For this reason, only use it for development purposes. diff --git a/packages/aws-cdk/lib/api/aws-auth/sdk.ts b/packages/aws-cdk/lib/api/aws-auth/sdk.ts index bfd30f4324a31..c45098277fbf1 100644 --- a/packages/aws-cdk/lib/api/aws-auth/sdk.ts +++ b/packages/aws-cdk/lib/api/aws-auth/sdk.ts @@ -61,6 +61,7 @@ export interface ISDK { secretsManager(): AWS.SecretsManager; kms(): AWS.KMS; stepFunctions(): AWS.StepFunctions; + codeBuild(): AWS.CodeBuild } /** @@ -180,6 +181,10 @@ export class SDK implements ISDK { return this.wrapServiceErrorHandling(new AWS.StepFunctions(this.config)); } + public codeBuild(): AWS.CodeBuild { + return this.wrapServiceErrorHandling(new AWS.CodeBuild(this.config)); + } + public async currentAccount(): Promise { // Get/refresh if necessary before we can access `accessKeyId` await this.forceCredentialRetrieval(); diff --git a/packages/aws-cdk/lib/api/hotswap-deployments.ts b/packages/aws-cdk/lib/api/hotswap-deployments.ts index ef6c64da09652..286fe9fedd72f 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -5,6 +5,7 @@ import * as colors from 'colors/safe'; import { print } from '../logging'; import { ISDK, Mode, SdkProvider } from './aws-auth'; import { DeployStackResult } from './deploy-stack'; +import { isHotswappableCodeBuildProjectChange } from './hotswap/code-build-projects'; import { ICON, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate, ListStackResources } from './hotswap/common'; import { isHotswappableEcsServiceChange } from './hotswap/ecs-services'; import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template'; @@ -77,6 +78,7 @@ async function findAllHotswappableChanges( isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), + isHotswappableCodeBuildProjectChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate), ]); } }); diff --git a/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts b/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts new file mode 100644 index 0000000000000..cf6298a917701 --- /dev/null +++ b/packages/aws-cdk/lib/api/hotswap/code-build-projects.ts @@ -0,0 +1,67 @@ +import * as AWS from 'aws-sdk'; +import { ISDK } from '../aws-auth'; +import { ChangeHotswapImpact, ChangeHotswapResult, establishResourcePhysicalName, HotswapOperation, HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys } from './common'; +import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; + +export async function isHotswappableCodeBuildProjectChange( + logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + if (change.newValue.Type !== 'AWS::CodeBuild::Project') { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + + const updateProjectInput: AWS.CodeBuild.UpdateProjectInput = { + name: '', + }; + for (const updatedPropName in change.propertyUpdates) { + const updatedProp = change.propertyUpdates[updatedPropName]; + switch (updatedPropName) { + case 'Source': + updateProjectInput.source = transformObjectKeys( + await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue), + convertSourceCloudformationKeyToSdkKey, + ); + break; + case 'Environment': + updateProjectInput.environment = await transformObjectKeys( + await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue), + lowerCaseFirstCharacter, + ); + break; + case 'SourceVersion': + updateProjectInput.sourceVersion = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue); + break; + default: + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + } + + const projectName = await establishResourcePhysicalName(logicalId, change.newValue.Properties?.Name, evaluateCfnTemplate); + if (!projectName) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; + } + updateProjectInput.name = projectName; + return new ProjectHotswapOperation(updateProjectInput); +} + +class ProjectHotswapOperation implements HotswapOperation { + public readonly service = 'codebuild' + public readonly resourceNames: string[]; + + constructor( + private readonly updateProjectInput: AWS.CodeBuild.UpdateProjectInput, + ) { + this.resourceNames = [updateProjectInput.name]; + } + + public async apply(sdk: ISDK): Promise { + return sdk.codeBuild().updateProject(this.updateProjectInput).promise(); + } +} + +function convertSourceCloudformationKeyToSdkKey(key: string): string { + if (key.toLowerCase() === 'buildspec') { + return key.toLowerCase(); + } + return lowerCaseFirstCharacter(key); +} diff --git a/packages/aws-cdk/lib/api/hotswap/common.ts b/packages/aws-cdk/lib/api/hotswap/common.ts index 7614bf2005e10..479a08ebd3986 100644 --- a/packages/aws-cdk/lib/api/hotswap/common.ts +++ b/packages/aws-cdk/lib/api/hotswap/common.ts @@ -82,3 +82,31 @@ export async function establishResourcePhysicalName( } return evaluateCfnTemplate.findPhysicalNameFor(logicalId); } + +/** + * This function transforms all keys (recursively) in the provided `val` object. + * + * @param val The object whose keys need to be transformed. + * @param transform The function that will be applied to each key. + * @returns A new object with the same values as `val`, but with all keys transformed according to `transform`. + */ +export function transformObjectKeys(val: any, transform: (str: string) => string): any { + if (val == null || typeof val !== 'object') { + return val; + } + if (Array.isArray(val)) { + return val.map((input: any) => transformObjectKeys(input, transform)); + } + const ret: { [k: string]: any; } = {}; + for (const [k, v] of Object.entries(val)) { + ret[transform(k)] = transformObjectKeys(v, transform); + } + return ret; +} + +/** + * This function lower cases the first character of the string provided. + */ +export function lowerCaseFirstCharacter(str: string): string { + return str.length > 0 ? `${str[0].toLowerCase()}${str.substr(1)}` : str; +} diff --git a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts index 09ec3d4f23d85..9518d3c832c37 100644 --- a/packages/aws-cdk/lib/api/hotswap/ecs-services.ts +++ b/packages/aws-cdk/lib/api/hotswap/ecs-services.ts @@ -1,6 +1,6 @@ import * as AWS from 'aws-sdk'; import { ISDK } from '../aws-auth'; -import { ChangeHotswapImpact, ChangeHotswapResult, establishResourcePhysicalName, HotswapOperation, HotswappableChangeCandidate } from './common'; +import { ChangeHotswapImpact, ChangeHotswapResult, establishResourcePhysicalName, HotswapOperation, HotswappableChangeCandidate, lowerCaseFirstCharacter, transformObjectKeys } from './common'; import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; export async function isHotswappableEcsServiceChange( @@ -90,7 +90,7 @@ class EcsServiceHotswapOperation implements HotswapOperation { // Step 1 - update the changed TaskDefinition, creating a new TaskDefinition Revision // we need to lowercase the evaluated TaskDef from CloudFormation, // as the AWS SDK uses lowercase property names for these - const lowercasedTaskDef = lowerCaseFirstCharacterOfObjectKeys(this.taskDefinitionResource); + const lowercasedTaskDef = transformObjectKeys(this.taskDefinitionResource, lowerCaseFirstCharacter); const registerTaskDefResponse = await sdk.ecs().registerTaskDefinition(lowercasedTaskDef).promise(); const taskDefRevArn = registerTaskDefResponse.taskDefinition?.taskDefinitionArn; @@ -172,21 +172,3 @@ class EcsServiceHotswapOperation implements HotswapOperation { })); } } - -function lowerCaseFirstCharacterOfObjectKeys(val: any): any { - if (val == null || typeof val !== 'object') { - return val; - } - if (Array.isArray(val)) { - return val.map(lowerCaseFirstCharacterOfObjectKeys); - } - const ret: { [k: string]: any; } = {}; - for (const [k, v] of Object.entries(val)) { - ret[lowerCaseFirstCharacter(k)] = lowerCaseFirstCharacterOfObjectKeys(v); - } - return ret; -} - -function lowerCaseFirstCharacter(str: string): string { - return str.length > 0 ? `${str[0].toLowerCase()}${str.substr(1)}` : str; -} diff --git a/packages/aws-cdk/test/api/hotswap/code-build-projects-hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/code-build-projects-hotswap-deployments.test.ts new file mode 100644 index 0000000000000..fa0de06780ccd --- /dev/null +++ b/packages/aws-cdk/test/api/hotswap/code-build-projects-hotswap-deployments.test.ts @@ -0,0 +1,614 @@ +import { CodeBuild } from 'aws-sdk'; +import * as setup from './hotswap-test-setup'; + +let hotswapMockSdkProvider: setup.HotswapMockSdkProvider; +let mockUpdateProject: (params: CodeBuild.UpdateProjectInput) => CodeBuild.UpdateProjectOutput; + +beforeEach(() => { + hotswapMockSdkProvider = setup.setupHotswapTests(); + mockUpdateProject = jest.fn(); + hotswapMockSdkProvider.setUpdateProjectMock(mockUpdateProject); +}); + +test('returns undefined when a new CodeBuild Project is added to the Stack', async () => { + // GIVEN + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); +}); + +test('calls the updateProject() API when it receives only a source difference in a CodeBuild project', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: 'my-project', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'new-spec', + Type: 'NO_SOURCE', + }, + Name: 'my-project', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateProject).toHaveBeenCalledWith({ + name: 'my-project', + source: { + type: 'NO_SOURCE', + buildspec: 'new-spec', + }, + }); +}); + +test('calls the updateProject() API when it receives only a source version difference in a CodeBuild project', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: 'my-project', + SourceVersion: 'v1', + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: 'my-project', + SourceVersion: 'v2', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateProject).toHaveBeenCalledWith({ + name: 'my-project', + sourceVersion: 'v2', + }); +}); + +test('calls the updateProject() API when it receives only an environment difference in a CodeBuild project', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: 'my-project', + Environment: { + ComputeType: 'BUILD_GENERAL1_SMALL', + EnvironmentVariables: [ + { + Name: 'SUPER_IMPORTANT_ENV_VAR', + Type: 'PLAINTEXT', + Value: 'super cool value', + }, + { + Name: 'SECOND_IMPORTANT_ENV_VAR', + Type: 'PLAINTEXT', + Value: 'yet another super cool value', + }, + ], + Image: 'aws/codebuild/standard:1.0', + ImagePullCredentialsType: 'CODEBUILD', + PrivilegedMode: false, + Type: 'LINUX_CONTAINER', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: 'my-project', + Environment: { + ComputeType: 'BUILD_GENERAL1_SMALL', + EnvironmentVariables: [ + { + Name: 'SUPER_IMPORTANT_ENV_VAR', + Type: 'PLAINTEXT', + Value: 'changed value', + }, + { + Name: 'NEW_IMPORTANT_ENV_VAR', + Type: 'PLAINTEXT', + Value: 'new value', + }, + ], + Image: 'aws/codebuild/standard:1.0', + ImagePullCredentialsType: 'CODEBUILD', + PrivilegedMode: false, + Type: 'LINUX_CONTAINER', + }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateProject).toHaveBeenCalledWith({ + name: 'my-project', + environment: { + computeType: 'BUILD_GENERAL1_SMALL', + environmentVariables: [ + { + name: 'SUPER_IMPORTANT_ENV_VAR', + type: 'PLAINTEXT', + value: 'changed value', + }, + { + name: 'NEW_IMPORTANT_ENV_VAR', + type: 'PLAINTEXT', + value: 'new value', + }, + ], + image: 'aws/codebuild/standard:1.0', + imagePullCredentialsType: 'CODEBUILD', + privilegedMode: false, + type: 'LINUX_CONTAINER', + }, + }); +}); + +test("correctly evaluates the project's name when it references a different resource from the template", async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: { + 'Fn::Join': ['-', [ + { Ref: 'Bucket' }, + 'project', + ]], + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries(setup.stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'mybucket')); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'new-spec', + Type: 'NO_SOURCE', + }, + Name: { + 'Fn::Join': ['-', [ + { Ref: 'Bucket' }, + 'project', + ]], + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateProject).toHaveBeenCalledWith({ + name: 'mybucket-project', + source: { + type: 'NO_SOURCE', + buildspec: 'new-spec', + }, + }); +}); + +test("correctly falls back to taking the project's name from the current stack if it can't evaluate it in the template", async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Parameters: { + Param1: { Type: 'String' }, + AssetBucketParam: { Type: 'String' }, + }, + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + Name: { Ref: 'Param1' }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries(setup.stackSummaryOf('CodeBuildProject', 'AWS::CodeBuild::Project', 'my-project')); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Parameters: { + Param1: { Type: 'String' }, + AssetBucketParam: { Type: 'String' }, + }, + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'new-spec', + Type: 'NO_SOURCE', + }, + Name: { Ref: 'Param1' }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact, { AssetBucketParam: 'asset-bucket' }); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateProject).toHaveBeenCalledWith({ + name: 'my-project', + source: { + type: 'NO_SOURCE', + buildspec: 'new-spec', + }, + }); +}); + +test("will not perform a hotswap deployment if it cannot find a Ref target (outside the project's name)", async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Parameters: { + Param1: { Type: 'String' }, + }, + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: { 'Fn::Sub': '${Param1}' }, + Type: 'NO_SOURCE', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries(setup.stackSummaryOf('CodeBuildProject', 'AWS::CodeBuild::Project', 'my-project')); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Parameters: { + Param1: { Type: 'String' }, + }, + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: { 'Fn::Sub': '${Param1}' }, + Type: 'CODEPIPELINE', + }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // THEN + await expect(() => + hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact), + ).rejects.toThrow(/Parameter or resource 'Param1' could not be found for evaluation/); +}); + +test("will not perform a hotswap deployment if it doesn't know how to handle a specific attribute (outside the project's name)", async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, + Type: 'NO_SOURCE', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + setup.pushStackResourceSummaries( + setup.stackSummaryOf('CodeBuildProject', 'AWS::CodeBuild::Project', 'my-project'), + setup.stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'my-bucket'), + ); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, + Type: 'S3', + }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // THEN + await expect(() => + hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact), + ).rejects.toThrow("We don't support the 'UnknownAttribute' attribute of the 'AWS::S3::Bucket' resource. This is a CDK limitation. Please report it at https://github.com/aws/aws-cdk/issues/new/choose"); +}); + +test('calls the updateProject() API when it receives a difference in a CodeBuild project with no name', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + }, + Metadata: { + 'aws:asset:path': 'current-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'new-spec', + Type: 'NO_SOURCE', + }, + }, + Metadata: { + 'aws:asset:path': 'current-path', + }, + }, + }, + }, + }); + + // WHEN + setup.pushStackResourceSummaries(setup.stackSummaryOf('CodeBuildProject', 'AWS::CodeBuild::Project', 'mock-project-resource-id')); + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateProject).toHaveBeenCalledWith({ + name: 'mock-project-resource-id', + source: { + type: 'NO_SOURCE', + buildspec: 'new-spec', + }, + }); +}); + +test('does not call the updateProject() API when it receives a change that is not Source, SourceVersion, or Environment difference in a CodeBuild project', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + ConcurrentBuildLimit: 1, + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::CodeBuild::Project', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + ConcurrentBuildLimit: 2, + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateProject).not.toHaveBeenCalled(); +}); + +test('does not call the updateProject() API when a resource with type that is not AWS::CodeBuild::Project but has the same properties is changed', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + CodeBuildProject: { + Type: 'AWS::NotCodeBuild::NotAProject', + Properties: { + Source: { + BuildSpec: 'current-spec', + Type: 'NO_SOURCE', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + CodeBuildProject: { + Type: 'AWS::NotCodeBuild::NotAProject', + Properties: { + Source: { + BuildSpec: 'new-spec', + Type: 'NO_SOURCE', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(cdkStackArtifact); + + // THEN + expect(deployStackResult).toBeUndefined(); + expect(mockUpdateProject).not.toHaveBeenCalled(); +}); \ No newline at end of file diff --git a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts index 5dcddba443784..ff60aaeac3753 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts @@ -1,6 +1,7 @@ import * as cxapi from '@aws-cdk/cx-api'; import { CloudFormation } from 'aws-sdk'; import * as AWS from 'aws-sdk'; +import * as codebuild from 'aws-sdk/clients/codebuild'; import * as lambda from 'aws-sdk/clients/lambda'; import * as stepfunctions from 'aws-sdk/clients/stepfunctions'; import { DeployStackResult } from '../../../lib/api'; @@ -94,6 +95,12 @@ export class HotswapMockSdkProvider { }); } + public setUpdateProjectMock(mockUpdateProject: (input: codebuild.UpdateProjectInput) => codebuild.UpdateProjectOutput) { + this.mockSdkProvider.stubCodeBuild({ + updateProject: mockUpdateProject, + }); + } + public setInvokeLambdaMock(mockInvokeLambda: (input: lambda.InvocationRequest) => lambda.InvocationResponse) { this.mockSdkProvider.stubLambda({ invoke: mockInvokeLambda, diff --git a/packages/aws-cdk/test/util/mock-sdk.ts b/packages/aws-cdk/test/util/mock-sdk.ts index c97dccc98d7f8..aba55f53f71b6 100644 --- a/packages/aws-cdk/test/util/mock-sdk.ts +++ b/packages/aws-cdk/test/util/mock-sdk.ts @@ -110,6 +110,10 @@ export class MockSdkProvider extends SdkProvider { (this.sdk as any).stepFunctions = jest.fn().mockReturnValue(partialAwsService(stubs)); } + public stubCodeBuild(stubs: SyncHandlerSubsetOf) { + (this.sdk as any).codeBuild = jest.fn().mockReturnValue(partialAwsService(stubs)); + } + public stubGetEndpointSuffix(stub: () => string) { this.sdk.getEndpointSuffix = stub; } @@ -129,6 +133,7 @@ export class MockSdk implements ISDK { public readonly secretsManager = jest.fn(); public readonly kms = jest.fn(); public readonly stepFunctions = jest.fn(); + public readonly codeBuild = jest.fn(); public readonly getEndpointSuffix = jest.fn(); public readonly appendCustomUserAgent = jest.fn(); public readonly removeCustomUserAgent = jest.fn();