Skip to content

Commit 1df478b

Browse files
authoredDec 10, 2021
feat(cli): Hotswapping Support for S3 Bucket Deployments (#17638)
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*
1 parent 2b26796 commit 1df478b

7 files changed

+878
-3
lines changed
 

‎packages/aws-cdk/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ Hotswapping is currently supported for the following changes
364364
- Code asset changes of AWS Lambda functions.
365365
- Definition changes of AWS Step Functions State Machines.
366366
- Container asset changes of AWS ECS Services.
367+
- Website asset changes of AWS S3 Bucket Deployments.
367368

368369
**⚠ Note #1**: This command deliberately introduces drift in CloudFormation stacks in order to speed up deployments.
369370
For this reason, only use it for development purposes.

‎packages/aws-cdk/lib/api/hotswap-deployments.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, Hotswappabl
77
import { isHotswappableEcsServiceChange } from './hotswap/ecs-services';
88
import { EvaluateCloudFormationTemplate } from './hotswap/evaluate-cloudformation-template';
99
import { isHotswappableLambdaFunctionChange } from './hotswap/lambda-functions';
10+
import { isHotswappableS3BucketDeploymentChange } from './hotswap/s3-bucket-deployments';
1011
import { isHotswappableStateMachineChange } from './hotswap/stepfunctions-state-machines';
1112
import { CloudFormationStack } from './util/cloudformation';
1213

@@ -73,6 +74,7 @@ async function findAllHotswappableChanges(
7374
isHotswappableLambdaFunctionChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
7475
isHotswappableStateMachineChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
7576
isHotswappableEcsServiceChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
77+
isHotswappableS3BucketDeploymentChange(logicalId, resourceHotswapEvaluation, evaluateCfnTemplate),
7678
]);
7779
}
7880
});

‎packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts

+5
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export class EvaluateCloudFormationTemplate {
5050
return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId;
5151
}
5252

53+
public async findLogicalIdForPhysicalName(physicalName: string): Promise<string | undefined> {
54+
const stackResources = await this.stackResources.listStackResources();
55+
return stackResources.find(sr => sr.PhysicalResourceId === physicalName)?.LogicalResourceId;
56+
}
57+
5358
public findReferencesTo(logicalId: string): Array<ResourceDefinition> {
5459
const ret = new Array<ResourceDefinition>();
5560
for (const [resourceLogicalId, resourceDef] of Object.entries(this.template?.Resources ?? {})) {

‎packages/aws-cdk/lib/api/hotswap/lambda-functions.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, Hotswap
33
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
44

55
/**
6-
* Returns `false` if the change cannot be short-circuited,
7-
* `true` if the change is irrelevant from a short-circuit perspective
6+
* Returns `ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT` if the change cannot be short-circuited,
7+
* `ChangeHotswapImpact.IRRELEVANT` if the change is irrelevant from a short-circuit perspective
88
* (like a change to CDKMetadata),
99
* or a LambdaFunctionResource if the change can be short-circuited.
1010
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { ISDK } from '../aws-auth';
2+
import { ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, HotswappableChangeCandidate/*, establishResourcePhysicalName*/ } from './common';
3+
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
4+
5+
/**
6+
* This means that the value is required to exist by CloudFormation's API (or our S3 Bucket Deployment Lambda)
7+
* but the actual value specified is irrelevant
8+
*/
9+
export const REQUIRED_BY_CFN = 'required-to-be-present-by-cfn';
10+
11+
export async function isHotswappableS3BucketDeploymentChange(
12+
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
13+
): Promise<ChangeHotswapResult> {
14+
// In old-style synthesis, the policy used by the lambda to copy assets Ref's the assets directly,
15+
// meaning that the changes made to the Policy are artifacts that can be safely ignored
16+
if (change.newValue.Type === 'AWS::IAM::Policy') {
17+
return changeIsForS3DeployCustomResourcePolicy(logicalId, change, evaluateCfnTemplate);
18+
}
19+
20+
if (change.newValue.Type !== 'Custom::CDKBucketDeployment') {
21+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
22+
}
23+
24+
// note that this gives the ARN of the lambda, not the name. This is fine though, the invoke() sdk call will take either
25+
const functionName = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue.Properties?.ServiceToken);
26+
if (!functionName) {
27+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
28+
}
29+
30+
const customResourceProperties = await evaluateCfnTemplate.evaluateCfnExpression({
31+
...change.newValue.Properties,
32+
ServiceToken: undefined,
33+
});
34+
35+
return new S3BucketDeploymentHotswapOperation(functionName, customResourceProperties);
36+
}
37+
38+
class S3BucketDeploymentHotswapOperation implements HotswapOperation {
39+
public readonly service = 'custom-s3-deployment';
40+
41+
constructor(private readonly functionName: string, private readonly customResourceProperties: any) {
42+
}
43+
44+
public async apply(sdk: ISDK): Promise<any> {
45+
return sdk.lambda().invoke({
46+
FunctionName: this.functionName,
47+
// Lambda refuses to take a direct JSON object and requires it to be stringify()'d
48+
Payload: JSON.stringify({
49+
RequestType: 'Update',
50+
ResponseURL: REQUIRED_BY_CFN,
51+
PhysicalResourceId: REQUIRED_BY_CFN,
52+
StackId: REQUIRED_BY_CFN,
53+
RequestId: REQUIRED_BY_CFN,
54+
LogicalResourceId: REQUIRED_BY_CFN,
55+
ResourceProperties: stringifyObject(this.customResourceProperties), // JSON.stringify() doesn't turn the actual objects to strings, but the lambda expects strings
56+
}),
57+
}).promise();
58+
}
59+
}
60+
61+
async function changeIsForS3DeployCustomResourcePolicy(
62+
iamPolicyLogicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate,
63+
): Promise<ChangeHotswapResult> {
64+
const roles = change.newValue.Properties?.Roles;
65+
if (!roles) {
66+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
67+
}
68+
69+
for (const role of roles) {
70+
const roleLogicalId = await evaluateCfnTemplate.findLogicalIdForPhysicalName(await evaluateCfnTemplate.evaluateCfnExpression(role));
71+
if (!roleLogicalId) {
72+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
73+
}
74+
75+
const roleRefs = evaluateCfnTemplate.findReferencesTo(roleLogicalId);
76+
for (const roleRef of roleRefs) {
77+
if (roleRef.Type === 'AWS::Lambda::Function') {
78+
const lambdaRefs = evaluateCfnTemplate.findReferencesTo(roleRef.LogicalId);
79+
for (const lambdaRef of lambdaRefs) {
80+
// If S3Deployment -> Lambda -> Role and IAM::Policy -> Role, then this IAM::Policy change is an
81+
// artifact of old-style synthesis
82+
if (lambdaRef.Type !== 'Custom::CDKBucketDeployment') {
83+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
84+
}
85+
}
86+
} else if (roleRef.Type === 'AWS::IAM::Policy') {
87+
if (roleRef.LogicalId !== iamPolicyLogicalId) {
88+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
89+
}
90+
} else {
91+
return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT;
92+
}
93+
}
94+
}
95+
96+
return new EmptyHotswapOperation();
97+
}
98+
99+
function stringifyObject(obj: any): any {
100+
if (obj == null) {
101+
return obj;
102+
}
103+
if (Array.isArray(obj)) {
104+
return obj.map(stringifyObject);
105+
}
106+
if (typeof obj !== 'object') {
107+
return obj.toString();
108+
}
109+
110+
const ret: { [k: string]: any } = {};
111+
for (const [k, v] of Object.entries(obj)) {
112+
ret[k] = stringifyObject(v);
113+
}
114+
return ret;
115+
}
116+
117+
class EmptyHotswapOperation implements HotswapOperation {
118+
readonly service = 'empty';
119+
public async apply(sdk: ISDK): Promise<any> {
120+
return Promise.resolve(sdk);
121+
}
122+
}

‎packages/aws-cdk/test/api/hotswap/hotswap-test-setup.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ export function pushStackResourceSummaries(...items: CloudFormation.StackResourc
4242
}
4343

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

4849
export function stackSummaryOf(logicalId: string, resourceType: string, physicalResourceId: string): CloudFormation.StackResourceSummary {
@@ -87,6 +88,12 @@ export class HotswapMockSdkProvider {
8788
});
8889
}
8990

91+
public setInvokeLambdaMock(mockInvokeLambda: (input: lambda.InvocationRequest) => lambda.InvocationResponse) {
92+
this.mockSdkProvider.stubLambda({
93+
invoke: mockInvokeLambda,
94+
});
95+
}
96+
9097
public stubEcs(stubs: SyncHandlerSubsetOf<AWS.ECS>, additionalProperties: { [key: string]: any } = {}): void {
9198
this.mockSdkProvider.stubEcs(stubs, additionalProperties);
9299
}

‎packages/aws-cdk/test/api/hotswap/s3-bucket-hotswap-deployments.test.ts

+738
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.