Skip to content

Commit

Permalink
feat(cli): hotswap deployments for CodeBuild projects (aws#18161)
Browse files Browse the repository at this point in the history
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*
  • Loading branch information
madeline-k authored and TikiTDO committed Feb 21, 2022
1 parent 77184a6 commit f088961
Show file tree
Hide file tree
Showing 9 changed files with 731 additions and 20 deletions.
1 change: 1 addition & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions packages/aws-cdk/lib/api/aws-auth/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export interface ISDK {
secretsManager(): AWS.SecretsManager;
kms(): AWS.KMS;
stepFunctions(): AWS.StepFunctions;
codeBuild(): AWS.CodeBuild
}

/**
Expand Down Expand Up @@ -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<Account> {
// Get/refresh if necessary before we can access `accessKeyId`
await this.forceCredentialRetrieval();
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -77,6 +78,7 @@ async function findAllHotswappableChanges(
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableCodeBuildProjectChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
});
Expand Down
67 changes: 67 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/code-build-projects.ts
Original file line number Diff line number Diff line change
@@ -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<ChangeHotswapResult> {
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<any> {
return sdk.codeBuild().updateProject(this.updateProjectInput).promise();
}
}

function convertSourceCloudformationKeyToSdkKey(key: string): string {
if (key.toLowerCase() === 'buildspec') {
return key.toLowerCase();
}
return lowerCaseFirstCharacter(key);
}
28 changes: 28 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
22 changes: 2 additions & 20 deletions packages/aws-cdk/lib/api/hotswap/ecs-services.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
Loading

0 comments on commit f088961

Please sign in to comment.