Skip to content

Commit

Permalink
feat(cli): Hotswapping Support for S3 Bucket Deployments (#17638)
Browse files Browse the repository at this point in the history
This PR adds hotswap support for S3 Bucket Deployments. 

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
comcalvi authored Dec 10, 2021
1 parent 2b26796 commit 1df478b
Show file tree
Hide file tree
Showing 7 changed files with 878 additions and 3 deletions.
1 change: 1 addition & 0 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ Hotswapping is currently supported for the following changes
- Code asset changes of AWS Lambda functions.
- Definition changes of AWS Step Functions State Machines.
- Container asset changes of AWS ECS Services.
- Website asset changes of AWS S3 Bucket Deployments.

**⚠ 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
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 @@ -7,6 +7,7 @@ import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, Hotswappabl
import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template';
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
import { isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
import { CloudFormationStack } from './util/cloudformation';

Expand Down Expand Up @@ -73,6 +74,7 @@ async function findAllHotswappableChanges(
isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
]);
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ export class EvaluateCloudFormationTemplate {
return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId;
}

public async findLogicalIdForPhysicalName(physicalName: string): Promise<string | undefined> {
const stackResources = await this.stackResources.listStackResources();
return stackResources.find(sr => sr.PhysicalResourceId === physicalName)?.LogicalResourceId;
}

public findReferencesTo(logicalId: string): Array<ResourceDefinition> {
const ret = new Array<ResourceDefinition>();
for (const [resourceLogicalId, resourceDef] of Object.entries(this.template?.Resources ?? {})) {
Expand Down
4 changes: 2 additions & 2 deletions packages/aws-cdk/lib/api/hotswap/lambda-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, Hotswap
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

/**
* Returns `false` if the change cannot be short-circuited,
* `true` if the change is irrelevant from a short-circuit perspective
* Returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if the change cannot be short-circuited,
* `ChangeHotswapImpact.IRRELEVANT` if the change is irrelevant from a short-circuit perspective
* (like a change to CDKMetadata),
* or a LambdaFunctionResource if the change can be short-circuited.
*/
Expand Down
122 changes: 122 additions & 0 deletions packages/aws-cdk/lib/api/hotswap/s3-bucket-deployments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { ISDK } from '../aws-auth';
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate/*, establishResourcePhysicalName*/ } from './common';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';

/**
* This means that the value is required to exist by CloudFormation's API (or our S3 Bucket Deployment Lambda)
* but the actual value specified is irrelevant
*/
export const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn';

export async function isHotswappableS3BucketDeploymentChange(
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
// In old-style synthesis, the policy used by the lambda to copy assets Ref's the assets directly,
// meaning that the changes made to the Policy are artifacts that can be safely ignored
if (change.newValue.Type === 'AWS::IAM::Policy') {
return changeIsForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate);
}

if (change.newValue.Type !== 'Custom::CDKBucketDeployment') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

// note that this gives the ARN of the lambda, not the name. This is fine though, the invoke() sdk call will take either
const functionName = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue.Properties?.ServiceToken);
if (!functionName) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const customResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression({
...change.newValue.Properties,
ServiceToken: undefined,
});

return new S3BucketDeploymentHotswapOperation(functionName, customResourceProperties);
}

class S3BucketDeploymentHotswapOperation implements HotswapOperation {
public readonly service = 'custom-s3-deployment';

constructor(private readonly functionName: string, private readonly customResourceProperties: any) {
}

public async apply(sdk: ISDK): Promise<any> {
return sdk.lambda().invoke({
FunctionName: this.functionName,
// Lambda refuses to take a direct JSON object and requires it to be stringify()'d
Payload: JSON.stringify({
RequestType: 'Update',
ResponseURL: REQUIRED_BY_CFN,
PhysicalResourceId: REQUIRED_BY_CFN,
StackId: REQUIRED_BY_CFN,
RequestId: REQUIRED_BY_CFN,
LogicalResourceId: REQUIRED_BY_CFN,
ResourceProperties: stringifyObject(this.customResourceProperties), // JSON.stringify() doesn't turn the actual objects to strings, but the lambda expects strings
}),
}).promise();
}
}

async function changeIsForS3DeployCustomResourcePolicy(
iamPolicyLogicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
): Promise<ChangeHotswapResult> {
const roles = change.newValue.Properties?.Roles;
if (!roles) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

for (const role of roles) {
const roleLogicalId = await evaluateCfnTemplate.findLogicalIdForPhysicalName(await evaluateCfnTemplate.evaluateCfnExpression(role));
if (!roleLogicalId) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}

const roleRefs = evaluateCfnTemplate.findReferencesTo(roleLogicalId);
for (const roleRef of roleRefs) {
if (roleRef.Type === 'AWS::Lambda::Function') {
const lambdaRefs = evaluateCfnTemplate.findReferencesTo(roleRef.LogicalId);
for (const lambdaRef of lambdaRefs) {
// If S3Deployment -> Lambda -> Role and IAM::Policy -> Role, then this IAM::Policy change is an
// artifact of old-style synthesis
if (lambdaRef.Type !== 'Custom::CDKBucketDeployment') {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}
} else if (roleRef.Type === 'AWS::IAM::Policy') {
if (roleRef.LogicalId !== iamPolicyLogicalId) {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
} else {
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
}
}
}

return new EmptyHotswapOperation();
}

function stringifyObject(obj: any): any {
if (obj == null) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(stringifyObject);
}
if (typeof obj !== 'object') {
return obj.toString();
}

const ret: { [k: string]: any } = {};
for (const [k, v] of Object.entries(obj)) {
ret[k] = stringifyObject(v);
}
return ret;
}

class EmptyHotswapOperation implements HotswapOperation {
readonly service = 'empty';
public async apply(sdk: ISDK): Promise<any> {
return Promise.resolve(sdk);
}
}
9 changes: 8 additions & 1 deletion packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export function pushStackResourceSummaries(...items: CloudFormation.StackResourc
}

export function setCurrentCfnStackTemplate(template: Template) {
currentCfnStack.setTemplate(template);
const templateDeepCopy = JSON.parse(JSON.stringify(template)); // deep copy the template, so our tests can mutate one template instead of creating two
currentCfnStack.setTemplate(templateDeepCopy);
}

export function stackSummaryOf(logicalId: string, resourceType: string, physicalResourceId: string): CloudFormation.StackResourceSummary {
Expand Down Expand Up @@ -87,6 +88,12 @@ export class HotswapMockSdkProvider {
});
}

public setInvokeLambdaMock(mockInvokeLambda: (input: lambda.InvocationRequest) => lambda.InvocationResponse) {
this.mockSdkProvider.stubLambda({
invoke: mockInvokeLambda,
});
}

public stubEcs(stubs: SyncHandlerSubsetOf<AWS.ECS>, additionalProperties: { [key: string]: any } = {}): void {
this.mockSdkProvider.stubEcs(stubs, additionalProperties);
}
Expand Down
Loading

0 comments on commit 1df478b

Please sign in to comment.