8000 feat(cli): support `Fn::ImportValue` intrinsic function for hotswap d… · aws/aws-cdk@a54ea0f · GitHub
[go: up one dir, main page]

Skip to content

Commit a54ea0f

Browse files
authored
feat(cli): support Fn::ImportValue intrinsic function for hotswap deployments (#27292)
## Purpose 🎯 Extend the `EvaluateCloudFormationTemplate` class to support the `Fn::ImportValue` intrinsic function. This allows for more diverse templates to be evaluated for the purposes of determining eligibility for `--hotswap` deployments Closes #21320 ## Approach 🧠 Implement `LazyLookupExport` in similar fashion to `LazyListStackResources` to cache required CloudFormation API calls _(preference was to implement using a generator function instead so style is not entirely consistent, is this an issue?)_ Add some basic unit tests for `EvaluateCloudFormationTemplate.evaluateCfnExpression()` is they were absent, then add some tests for `Fn::ImportValue` ## Todo 📝 - [x] Update doco where appropriate - [x] Add to hotswap deployment tests - [x] Look for appropriate integration tests to update ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 447b63c commit a54ea0f

File tree

8 files changed

+436
-8
lines changed

8 files changed

+436
-8
lines changed

packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js

Lines changed: 24 additions & 2 deletions
Original file line numbe 6855 rDiff line numberDiff line change
@@ -252,8 +252,12 @@ class LambdaHotswapStack extends cdk.Stack {
252252
handler: 'index.handler',
253253
description: process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "description",
254254
environment: {
255-
SomeVariable: process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "environment",
256-
}
255+
SomeVariable:
256+
process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "environment",
257+
ImportValueVariable: process.env.USE_IMPORT_VALUE_LAMBDA_PROPERTY
258+
? cdk.Fn.importValue(TEST_EXPORT_OUTPUT_NAME)
259+
: "no-import",
260+
},
257261
});
258262

259263
new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName });
@@ -343,6 +347,22 @@ class ConditionalResourceStack extends cdk.Stack {
343347
}
344348
}
345349

350+
const TEST_EXPORT_OUTPUT_NAME = 'test-export-output';
351+
352+
class ExportValueStack extends cdk.Stack {
353+
constructor(parent, id, props) {
354+
super(parent, id, props);
355+
356+
// just need any resource to exist within the stack
357+
const topic = new sns.Topic(this, 'Topic');
358+
359+
new cdk.CfnOutput(this, 'ExportValueOutput', {
360+
exportName: TEST_EXPORT_OUTPUT_NAME,
361+
value: topic.topicArn,
362+
});
363+
}
364+
}
365+
346366
class BundlingStage extends cdk.Stage {
347367
constructor(parent, id, props) {
348368
super(parent, id, props);
@@ -450,6 +470,8 @@ switch (stackSet) {
450470

451471
new ImportableStack(app, `${stackPrefix}-importable-stack`);
452472

473+
new ExportValueStack(app, `${stackPrefix}-export-value-stack`);
474+
453 684D 475
new BundlingStage(app, `${stackPrefix}-bundling-stage`);
454476
break;
455477

packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,6 +1226,48 @@ integTest('hotswap deployment supports Lambda function\'s description and enviro
12261226
expect(deployOutput).toContain(`Lambda Function '${functionName}' hotswapped!`);
12271227
}));
12281228

1229+
integTest('hotswap deployment supports Fn::ImportValue intrinsic', withDefaultFixture(async (fixture) => {
1230+
// GIVEN
1231+
try {
1232+
await fixture.cdkDeploy('export-value-stack');
1233+
const stackArn = await fixture.cdkDeploy('lambda-hotswap', {
1234+
captureStderr: false,
1235+
modEnv: {
1236+
DYNAMIC_LAMBDA_PROPERTY_VALUE: 'original value',
1237+
USE_IMPORT_VALUE_LAMBDA_PROPERTY: 'true',
1238+
},
1239+
});
1240+
1241+
// WHEN
1242+
const deployOutput = await fixture.cdkDeploy('lambda-hotswap', {
1243+
options: ['--hotswap'],
1244+
captureStderr: true,
1245+
onlyStderr: true,
1246+
modEnv: {
1247+
DYNAMIC_LAMBDA_PROPERTY_VALUE: 'new value',
1248+
USE_IMPORT_VALUE_LAMBDA_PROPERTY: 'true',
1249+
},
1250+
});
1251+
1252+
const response = await fixture.aws.cloudFormation('describeStacks', {
1253+
StackName: stackArn,
1254+
});
1255+
const functionName = response.Stacks?.[0].Outputs?.[0].OutputValue;
1256+
1257+
// THEN
1258+
1259+
// The deployment should not trigger a full deployment, thus the stack's status must remains
1260+
// "CREATE_COMPLETE"
1261+
expect(response.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE');
1262+
expect(deployOutput).toContain(`Lambda Function '${functionName}' hotswapped!`);
1263+
1264+
} finally {
1265+
// Ensure cleanup in reverse order due to use of import/export
1266+
await fixture.cdkDestroy('lambda-hotswap');
1267+
await fixture.cdkDestroy('export-value-stack');
1268+
}
1269+
}));
1270+
12291271
async function listChildren(parent: string, pred: (x: string) => Promise<boolean>) {
12301272
const ret = new Array<string>();
12311273
for (const child of await fs.readdir(parent, { encoding: 'utf-8' })) {

packages/aws-cdk/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,18 @@ and might have breaking changes in the future.
434434

435435
**⚠ Note #3**: Expected defaults for certain parameters may be different with the hotswap parameter. For example, an ECS service's minimum healthy percentage will currently be set to 0. Please review the source accordingly if this occurs.
436436

437+
**⚠ Note #4**: Only usage of certain [CloudFormation intrinsic functions](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html) are supported as part of a hotswapped deployment. At time of writing, these are:
438+
439+
- `Ref`
440+
- `Fn::GetAtt` *
441+
- `Fn::ImportValue`
442+
- `Fn::Join`
443+
- `Fn::Select`
444+
- `Fn::Split`
445+
- `Fn::Sub`
446+
447+
> *: `Fn::GetAtt` is only partially supported. Refer to [this implementation](https://github.com/aws/aws-cdk/blob/main/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts#L477-L492) for supported resources and attributes.
448+
437449
### `cdk watch`
438450

439451
The `watch` command is similar to `deploy`,

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

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as AWS from 'aws-sdk';
2+
import { PromiseResult } from 'aws-sdk/lib/request';
23
import { ISDK } from './aws-auth';
34
import { NestedStackNames } from './nested-stack-helpers';
45

@@ -34,7 +35,57 @@ export class LazyListStackResources implements ListStackResources {
3435
}
3536
}
3637

37-
export class CfnEvaluationException extends Error {}
38+
export interface LookupExport {
39+
lookupExport(name: string): Promise<AWS.CloudFormation.Export | undefined>;
40+
}
41+
42+
export class LookupExportError extends Error { }
43+
44+
export class LazyLookupExport implements LookupExport {
45+
private cachedExports: { [name: string]: AWS.CloudFormation.Export } = {}
46+
47+
constructor(private readonly sdk: ISDK) { }
48+
49+
async lookupExport(name: string): Promise<AWS.CloudFormation.Export | undefined> {
50+
if (this.cachedExports[name]) {
51+
return this.cachedExports[name];
52+
}
53+
54+
for await (const cfnExport of this.listExports()) {
55+
if (!cfnExport.Name) {
56+
continue; // ignore any result that omits a name
57+
}
58+
this.cachedExports[cfnExport.Name] = cfnExport;
59+
60+
if (cfnExport.Name === name) {
61+
return cfnExport;
62+
}
63+
64+
}
65+
66+
return undefined; // export not found
67+
}
68+
69+
private async * listExports() {
70+
let nextToken: string | undefined = undefined;
71+
while (true) {
72+
const response: PromiseResult<AWS.CloudFormation.ListExportsOutput, AWS.AWSError> = await this.sdk.cloudFormation().listExports({
73+
NextToken: nextToken,
74+
}).promise();
75+
76+
for (const cfnExport of response.Exports ?? []) {
77+
yield cfnExport;
78+
}
79+
80+
if (!response.NextToken) {
81+
return;
82+
}
83+
nextToken = response.NextToken;
84+
}
85+
}
86+
}
87+
88+
export class CfnEvaluationException extends Error { }
3889

3990
export interface ResourceDefinition {
4091
readonly LogicalId: string;
@@ -64,7 +115,8 @@ export class EvaluateCloudFormationTemplate {
64115
private readonly urlSuffix: (region: string) => string;
65116
private readonly sdk: ISDK;
66117
private readonly nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames };
67-
private readonly stackResources: LazyListStackResources;
118+
private readonly stackResources: ListStackResources;
119+
private readonly lookupExport: LookupExport;
68120

69121
private cachedUrlSuffix: string | undefined;
70122

@@ -90,6 +142,9 @@ export class EvaluateCloudFormationTemplate {
90142
// We need them to figure out the physical name of a resource in case it wasn't specified by the user.
91143
// We fetch it lazily, to save a service call, in case all hotswapped resources have their physical names set.
92144
this.stackResources = new LazyListStackResources(this.sdk, this.stackName);
145+
146+
// CloudFormation Exports lookup to be able to resolve Fn::ImportValue intrinsics in template
147+
this.lookupExport = new LazyLookupExport(this.sdk);
93148
}
94149

95150
// clones current EvaluateCloudFormationTemplate object, but updates the stack name
@@ -152,6 +207,14 @@ export class EvaluateCloudFormationTemplate {
152207

153208
public async evaluateCfnExpression(cfnExpression: any): Promise<any> {
154209
const self = this;
210+
/**
211+
* Evaluates CloudFormation intrinsic functions
212+
*
213+
* Note that supported intrinsic functions are documented in README.md -- please update
214+
* list of supported functions when adding new evaluations
215+
*
216+
* See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
217+
*/
155218
class CfnIntrinsics {
156219
public evaluateIntrinsic(intrinsic: Intrinsic): any {
157220
const intrinsicFunc = (this as any)[intrinsic.name];
@@ -214,6 +277,17 @@ export class EvaluateCloudFormationTemplate {
214277
}
215278
});
216279
}
280+
281+
async 'Fn::ImportValue'(name: string): Promise<string> {
282+
const exported = await self.lookupExport.lookupExport(name);
283+
if (!exported) {
284+
throw new CfnEvaluationException(`Export '${name}' could not be found for evaluation`);
285+
}
286+
if (!exported.Value) {
287+
throw new CfnEvaluationException(`Export '${name}' exists without a value`);
288+
}
289+
return exported.Value;
290+
}
217291
}
218292

219293
if (cfnExpression == null) {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ type HotswapDetector = (
1919
logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate
2020
) => Promise<ChangeHotswapResult>;
2121

22-
const RESOURCE_DETECTORS: { [key:string]: HotswapDetector } = {
22+
const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = {
2323
// Lambda
2424
'AWS::Lambda::Function': isHotswappableLambdaFunctionChange,
2525
'AWS::Lambda::Version': isHotswappableLambdaFunctionChange,
@@ -247,8 +247,8 @@ async function findNestedHotswappableChanges(
247247
/** Returns 'true' if a pair of changes is for the same resource. */
248248
function changesAreForSameResource(oldChange: cfn_diff.ResourceDifference, newChange: cfn_diff.ResourceDifference): boolean {
249249
return oldChange.oldResourceType === newChange.newResourceType &&
250-
// this isn't great, but I don't want to bring in something like underscore just for this comparison
251-
JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties);
250+
// this isn't great, but I don't want to bring in something like underscore just for this comparison
251+
JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties);
252252
}
253253

254254
function makeRenameDifference(
@@ -371,7 +371,7 @@ function logNonHotswappableChanges(nonHotswappableChanges: NonHotswappableChange
371371

372372
for (const change of nonHotswappableChanges) {
373373
change.rejectedChanges.length > 0 ?
374-
print(' logicalID: %s, type: %s, rejected changes: %s, reason: %s', chalk.bold(change.logicalId), chalk.bold(change.resourceType), chalk.bold(change.rejectedChanges), chalk.red(change.reason)):
374+
print(' logicalID: %s, type: %s, rejected changes: %s, reason: %s', chalk.bold(change.logicalId), chalk.bold(change.resourceType), chalk.bold(change.rejectedChanges), chalk.red(change.reason)) :
375375
print(' logicalID: %s, type: %s, reason: %s', chalk.bold(change.logicalId), chalk.bold(change.resourceType), chalk.red(change.reason));
376376
}
377377

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {
2+
CfnEvaluationException,
3+
EvaluateCloudFormationTemplate,
4+
Template,
5+
} from '../../lib/api/evaluate-cloudformation-template';
6+
import { MockSdk } from '../util/mock-sdk';
7+
8+
const listStackResources = jest.fn();
9+
const listExports: jest.Mock<AWS.CloudFormation.ListExportsOutput, AWS.CloudFormation.ListExportsInput[]> = jest.fn();
10+
const sdk = new MockSdk();
11+
sdk.stubCloudFormation({
12+
listExports,
13+
listStackResources,
14+
});
15+
16+
const createEvaluateCloudFormationTemplate = (template: Template) => new EvaluateCloudFormationTemplate({
17+
template,
18+
parameters: {},
19+
account: '0123456789',
20+
region: 'ap-south-east-2',
21+
partition: 'aws',
22+
urlSuffix: (region) => sdk.getEndpointSuffix(region),
23+
sdk,
24+
stackName: 'test-stack',
25+
});
26+
27+
describe('evaluateCfnExpression', () => {
28+
describe('simple literal expressions', () => {
29+
const template: Template = {};
30+
const evaluateCfnTemplate = createEvaluateCloudFormationTemplate(template);
31+
32+
test('resolves Fn::Join correctly', async () => {
33+
// WHEN
34+
const result = await evaluateCfnTemplate.evaluateCfnExpression({
35+
'Fn::Join': [':', ['a', 'b', 'c']],
36+
});
37+
38+
// THEN
39+
expect(result).toEqual('a:b:c');
40+
});
41+
42+
test('resolves Fn::Split correctly', async () => {
43+
// WHEN
44+
const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Split': ['|', 'a|b|c'] });
45+
46+
// THEN
47+
expect(result).toEqual(['a', 'b', 'c']);
48+
});
49+
50+
test('resolves Fn::Select correctly', async () => {
51+
// WHEN
52+
const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Select': ['1', ['apples', 'grapes', 'oranges', 'mangoes']] });
53+
54+
// THEN
55+
expect(result).toEqual('grapes');
56+
});
57+
58+
test('resolves Fn::Sub correctly', async () => {
59+
// WHEN
60+
const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Sub': ['Testing Fn::Sub Foo=${Foo} Bar=${Bar}', { Foo: 'testing', Bar: 1 }] });
61+
62+
// THEN
63+
expect(result).toEqual('Testing Fn::Sub Foo=testing Bar=1');
64+
});
65+
});
66+
67+
describe('resolving Fn::ImportValue', () => {
68+
const template: Template = {};
69+
const evaluateCfnTemplate = createEvaluateCloudFormationTemplate(template);
70+
71+
const createMockExport = (num: number) => ({
72+
ExportingStackId: `test-exporting-stack-id-${num}`,
73+
Name: `test-name-${num}`,
74+
Value: `test-value-${num}`,
75+
});
76+
77+
beforeEach(async () => {
78+
listExports.mockReset();
79+
listExports
80+
.mockReturnValueOnce({
81+
Exports: [
82+
createMockExport(1),
83+
createMockExport(2),
84+
createMockExport(3),
85+
],
86+
NextToken: 'next-token-1',
87+
})
88+
.mockReturnValueOnce({
89+
Exports: [
90+
createMockExport(4),
91+
createMockExport(5),
92+
createMockExport(6),
93+
],
94+
NextToken: undefined,
95+
});
96+
});
97+
98+
test('resolves Fn::ImportValue using lookup', async () => {
99+
const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::ImportValue': 'test-name-5' });
100+
expect(result).toEqual('test-value-5');
101+
});
102+
103+
test('throws error when Fn::ImportValue cannot be resolved', async () => {
104+
const evaluate = () => evaluateCfnTemplate.evaluateCfnExpression({
105+
'Fn::ImportValue': 'blah',
106+
});
107+
await expect(evaluate).rejects.toBeInstanceOf(CfnEvaluationException);
108+
});
109+
});
110+
});

0 commit comments

Comments
 (0)
0