diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index 8585ed665bdfd..048e6fabd5165 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -252,8 +252,12 @@ class LambdaHotswapStack extends cdk.Stack { handler: 'index.handler', description: process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "description", environment: { - SomeVariable: process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "environment", - } + SomeVariable: + process.env.DYNAMIC_LAMBDA_PROPERTY_VALUE ?? "environment", + ImportValueVariable: process.env.USE_IMPORT_VALUE_LAMBDA_PROPERTY + ? cdk.Fn.importValue(TEST_EXPORT_OUTPUT_NAME) + : "no-import", + }, }); new cdk.CfnOutput(this, 'FunctionName', { value: fn.functionName }); @@ -343,6 +347,22 @@ class ConditionalResourceStack extends cdk.Stack { } } +const TEST_EXPORT_OUTPUT_NAME = 'test-export-output'; + +class ExportValueStack extends cdk.Stack { + constructor(parent, id, props) { + super(parent, id, props); + + // just need any resource to exist within the stack + const topic = new sns.Topic(this, 'Topic'); + + new cdk.CfnOutput(this, 'ExportValueOutput', { + exportName: TEST_EXPORT_OUTPUT_NAME, + value: topic.topicArn, + }); + } +} + class BundlingStage extends cdk.Stage { constructor(parent, id, props) { super(parent, id, props); @@ -450,6 +470,8 @@ switch (stackSet) { new ImportableStack(app, `${stackPrefix}-importable-stack`); + new ExportValueStack(app, `${stackPrefix}-export-value-stack`); + new BundlingStage(app, `${stackPrefix}-bundling-stage`); break; diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index 93b25eed0c6b6..ccdf07b166f33 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -1226,6 +1226,48 @@ integTest('hotswap deployment supports Lambda function\'s description and enviro expect(deployOutput).toContain(`Lambda Function '${functionName}' hotswapped!`); })); +integTest('hotswap deployment supports Fn::ImportValue intrinsic', withDefaultFixture(async (fixture) => { + // GIVEN + try { + await fixture.cdkDeploy('export-value-stack'); + const stackArn = await fixture.cdkDeploy('lambda-hotswap', { + captureStderr: false, + modEnv: { + DYNAMIC_LAMBDA_PROPERTY_VALUE: 'original value', + USE_IMPORT_VALUE_LAMBDA_PROPERTY: 'true', + }, + }); + + // WHEN + const deployOutput = await fixture.cdkDeploy('lambda-hotswap', { + options: ['--hotswap'], + captureStderr: true, + onlyStderr: true, + modEnv: { + DYNAMIC_LAMBDA_PROPERTY_VALUE: 'new value', + USE_IMPORT_VALUE_LAMBDA_PROPERTY: 'true', + }, + }); + + const response = await fixture.aws.cloudFormation('describeStacks', { + StackName: stackArn, + }); + const functionName = response.Stacks?.[0].Outputs?.[0].OutputValue; + + // THEN + + // The deployment should not trigger a full deployment, thus the stack's status must remains + // "CREATE_COMPLETE" + expect(response.Stacks?.[0].StackStatus).toEqual('CREATE_COMPLETE'); + expect(deployOutput).toContain(`Lambda Function '${functionName}' hotswapped!`); + + } finally { + // Ensure cleanup in reverse order due to use of import/export + await fixture.cdkDestroy('lambda-hotswap'); + await fixture.cdkDestroy('export-value-stack'); + } +})); + async function listChildren(parent: string, pred: (x: string) => Promise) { const ret = new Array(); for (const child of await fs.readdir(parent, { encoding: 'utf-8' })) { diff --git a/packages/aws-cdk/README.md b/packages/aws-cdk/README.md index 3410e37169422..864c7de02c968 100644 --- a/packages/aws-cdk/README.md +++ b/packages/aws-cdk/README.md @@ -434,6 +434,18 @@ and might have breaking changes in the future. **⚠ 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. +**⚠ 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: + +- `Ref` +- `Fn::GetAtt` * +- `Fn::ImportValue` +- `Fn::Join` +- `Fn::Select` +- `Fn::Split` +- `Fn::Sub` + +> *: `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. + ### `cdk watch` The `watch` command is similar to `deploy`, diff --git a/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts index 6598287eded5b..258b64829e62a 100644 --- a/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts +++ b/packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts @@ -1,4 +1,5 @@ import * as AWS from 'aws-sdk'; +import { PromiseResult } from 'aws-sdk/lib/request'; import { ISDK } from './aws-auth'; import { NestedStackNames } from './nested-stack-helpers'; @@ -34,7 +35,57 @@ export class LazyListStackResources implements ListStackResources { } } -export class CfnEvaluationException extends Error {} +export interface LookupExport { + lookupExport(name: string): Promise; +} + +export class LookupExportError extends Error { } + +export class LazyLookupExport implements LookupExport { + private cachedExports: { [name: string]: AWS.CloudFormation.Export } = {} + + constructor(private readonly sdk: ISDK) { } + + async lookupExport(name: string): Promise { + if (this.cachedExports[name]) { + return this.cachedExports[name]; + } + + for await (const cfnExport of this.listExports()) { + if (!cfnExport.Name) { + continue; // ignore any result that omits a name + } + this.cachedExports[cfnExport.Name] = cfnExport; + + if (cfnExport.Name === name) { + return cfnExport; + } + + } + + return undefined; // export not found + } + + private async * listExports() { + let nextToken: string | undefined = undefined; + while (true) { + const response: PromiseResult = await this.sdk.cloudFormation().listExports({ + NextToken: nextToken, + }).promise(); + + for (const cfnExport of response.Exports ?? []) { + yield cfnExport; + } + + if (!response.NextToken) { + return; + } + nextToken = response.NextToken; + } + } +} + +export class CfnEvaluationException extends Error { } export interface ResourceDefinition { readonly LogicalId: string; @@ -64,7 +115,8 @@ export class EvaluateCloudFormationTemplate { private readonly urlSuffix: (region: string) => string; private readonly sdk: ISDK; private readonly nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames }; - private readonly stackResources: LazyListStackResources; + private readonly stackResources: ListStackResources; + private readonly lookupExport: LookupExport; private cachedUrlSuffix: string | undefined; @@ -90,6 +142,9 @@ export class EvaluateCloudFormationTemplate { // We need them to figure out the physical name of a resource in case it wasn't specified by the user. // We fetch it lazily, to save a service call, in case all hotswapped resources have their physical names set. this.stackResources = new LazyListStackResources(this.sdk, this.stackName); + + // CloudFormation Exports lookup to be able to resolve Fn::ImportValue intrinsics in template + this.lookupExport = new LazyLookupExport(this.sdk); } // clones current EvaluateCloudFormationTemplate object, but updates the stack name @@ -152,6 +207,14 @@ export class EvaluateCloudFormationTemplate { public async evaluateCfnExpression(cfnExpression: any): Promise { const self = this; + /** + * Evaluates CloudFormation intrinsic functions + * + * Note that supported intrinsic functions are documented in README.md -- please update + * list of supported functions when adding new evaluations + * + * See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html + */ class CfnIntrinsics { public evaluateIntrinsic(intrinsic: Intrinsic): any { const intrinsicFunc = (this as any)[intrinsic.name]; @@ -214,6 +277,17 @@ export class EvaluateCloudFormationTemplate { } }); } + + async 'Fn::ImportValue'(name: string): Promise { + const exported = await self.lookupExport.lookupExport(name); + if (!exported) { + throw new CfnEvaluationException(`Export '${name}' could not be found for evaluation`); + } + if (!exported.Value) { + throw new CfnEvaluationException(`Export '${name}' exists without a value`); + } + return exported.Value; + } } if (cfnExpression == null) { diff --git a/packages/aws-cdk/lib/api/hotswap-deployments.ts b/packages/aws-cdk/lib/api/hotswap-deployments.ts index 3b7737aa72e1e..0766c82573e6d 100644 --- a/packages/aws-cdk/lib/api/hotswap-deployments.ts +++ b/packages/aws-cdk/lib/api/hotswap-deployments.ts @@ -19,7 +19,7 @@ type HotswapDetector = ( logicalId: string, change: HotswappableChangeCandidate, evaluateCfnTemplate: EvaluateCloudFormationTemplate ) => Promise; -const RESOURCE_DETECTORS: { [key:string]: HotswapDetector } = { +const RESOURCE_DETECTORS: { [key: string]: HotswapDetector } = { // Lambda 'AWS::Lambda::Function': isHotswappableLambdaFunctionChange, 'AWS::Lambda::Version': isHotswappableLambdaFunctionChange, @@ -247,8 +247,8 @@ async function findNestedHotswappableChanges( /** Returns 'true' if a pair of changes is for the same resource. */ function changesAreForSameResource(oldChange: cfn_diff.ResourceDifference, newChange: cfn_diff.ResourceDifference): boolean { return oldChange.oldResourceType === newChange.newResourceType && - // this isn't great, but I don't want to bring in something like underscore just for this comparison - JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties); + // this isn't great, but I don't want to bring in something like underscore just for this comparison + JSON.stringify(oldChange.oldProperties) === JSON.stringify(newChange.newProperties); } function makeRenameDifference( @@ -371,7 +371,7 @@ function logNonHotswappableChanges(nonHotswappableChanges: NonHotswappableChange for (const change of nonHotswappableChanges) { change.rejectedChanges.length > 0 ? - 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)): + 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)) : print(' logicalID: %s, type: %s, reason: %s', chalk.bold(change.logicalId), chalk.bold(change.resourceType), chalk.red(change.reason)); } diff --git a/packages/aws-cdk/test/api/evaluate-cloudformation-template.test.ts b/packages/aws-cdk/test/api/evaluate-cloudformation-template.test.ts new file mode 100644 index 0000000000000..4a571838820a2 --- /dev/null +++ b/packages/aws-cdk/test/api/evaluate-cloudformation-template.test.ts @@ -0,0 +1,110 @@ +import { + CfnEvaluationException, + EvaluateCloudFormationTemplate, + Template, +} from '../../lib/api/evaluate-cloudformation-template'; +import { MockSdk } from '../util/mock-sdk'; + +const listStackResources = jest.fn(); +const listExports: jest.Mock = jest.fn(); +const sdk = new MockSdk(); +sdk.stubCloudFormation({ + listExports, + listStackResources, +}); + +const createEvaluateCloudFormationTemplate = (template: Template) => new EvaluateCloudFormationTemplate({ + template, + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + urlSuffix: (region) => sdk.getEndpointSuffix(region), + sdk, + stackName: 'test-stack', +}); + +describe('evaluateCfnExpression', () => { + describe('simple literal expressions', () => { + const template: Template = {}; + const evaluateCfnTemplate = createEvaluateCloudFormationTemplate(template); + + test('resolves Fn::Join correctly', async () => { + // WHEN + const result = await evaluateCfnTemplate.evaluateCfnExpression({ + 'Fn::Join': [':', ['a', 'b', 'c']], + }); + + // THEN + expect(result).toEqual('a:b:c'); + }); + + test('resolves Fn::Split correctly', async () => { + // WHEN + const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Split': ['|', 'a|b|c'] }); + + // THEN + expect(result).toEqual(['a', 'b', 'c']); + }); + + test('resolves Fn::Select correctly', async () => { + // WHEN + const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Select': ['1', ['apples', 'grapes', 'oranges', 'mangoes']] }); + + // THEN + expect(result).toEqual('grapes'); + }); + + test('resolves Fn::Sub correctly', async () => { + // WHEN + const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Sub': ['Testing Fn::Sub Foo=${Foo} Bar=${Bar}', { Foo: 'testing', Bar: 1 }] }); + + // THEN + expect(result).toEqual('Testing Fn::Sub Foo=testing Bar=1'); + }); + }); + + describe('resolving Fn::ImportValue', () => { + const template: Template = {}; + const evaluateCfnTemplate = createEvaluateCloudFormationTemplate(template); + + const createMockExport = (num: number) => ({ + ExportingStackId: `test-exporting-stack-id-${num}`, + Name: `test-name-${num}`, + Value: `test-value-${num}`, + }); + + beforeEach(async () => { + listExports.mockReset(); + listExports + .mockReturnValueOnce({ + Exports: [ + createMockExport(1), + createMockExport(2), + createMockExport(3), + ], + NextToken: 'next-token-1', + }) + .mockReturnValueOnce({ + Exports: [ + createMockExport(4), + createMockExport(5), + createMockExport(6), + ], + NextToken: undefined, + }); + }); + + test('resolves Fn::ImportValue using lookup', async () => { + const result = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::ImportValue': 'test-name-5' }); + expect(result).toEqual('test-value-5'); + }); + + test('throws error when Fn::ImportValue cannot be resolved', async () => { + const evaluate = () => evaluateCfnTemplate.evaluateCfnExpression({ + 'Fn::ImportValue': 'blah', + }); + await expect(evaluate).rejects.toBeInstanceOf(CfnEvaluationException); + }); + }); +}); diff --git a/packages/aws-cdk/test/api/hotswap/hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap/hotswap-deployments.test.ts index aab805a441f5b..c310c86d4b6b5 100644 --- a/packages/aws-cdk/test/api/hotswap/hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap/hotswap-deployments.test.ts @@ -630,4 +630,70 @@ describe.each([HotswapMode.FALL_BACK, HotswapMode.HOTSWAP_ONLY])('%p mode', (hot }); } }); + + test('can correctly reference Fn::ImportValue in hotswappable changes', async () => { + // GIVEN + setup.setCurrentCfnStackTemplate({ + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'current-bucket', + S3Key: 'old-key', + }, + FunctionName: 'aws-my-function', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }); + const cdkStackArtifact = setup.cdkStackArtifactOf({ + template: { + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'current-bucket', + S3Key: { + 'Fn::ImportValue': 'test-import', + }, + }, + FunctionName: 'aws-my-function', + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + const listExports = jest.fn().mockReturnValue({ + Exports: [ + { + ExportingStackId: 'test-exporting-stack-id', + Name: 'test-import', + Value: 'new-key', + }, + ], + }); + hotswapMockSdkProvider.mockSdkProvider.stubCloudFormation({ + listExports, + }); + + // WHEN + const deployStackResult = await hotswapMockSdkProvider.tryHotswapDeployment(hotswapMode, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateLambdaCode).toHaveBeenCalledWith({ + FunctionName: 'aws-my-function', + S3Bucket: 'current-bucket', + S3Key: 'new-key', + }); + }); }); diff --git a/packages/aws-cdk/test/api/lazy-lookup-export.test.ts b/packages/aws-cdk/test/api/lazy-lookup-export.test.ts new file mode 100644 index 0000000000000..86a04de7167cb --- /dev/null +++ b/packages/aws-cdk/test/api/lazy-lookup-export.test.ts @@ -0,0 +1,102 @@ +import * as AWS from 'aws-sdk'; +import { LazyLookupExport } from '../../lib/api/evaluate-cloudformation-template'; +import { MockSdk } from '../util/mock-sdk'; + +describe('LazyLookupExport', () => { + const listExports: jest.Mock = jest.fn(); + const mockSdk = new MockSdk(); + mockSdk.stubCloudFormation({ + listExports, + }); + + const createExport = (num: number) => ({ + ExportingStackId: `test-exporting-stack-id-${num}`, + Name: `test-name-${num}`, + Value: `test-value-${num}`, + }); + + it('skips over any results that omit Name property', async () => { + listExports.mockReturnValueOnce({ + Exports: [ + createExport(1), + createExport(2), + { + Value: 'value-without-name', + }, + createExport(3), + ], + NextToken: undefined, + }); + const lookup = new LazyLookupExport(mockSdk); + + const result = await lookup.lookupExport('test-name-3'); + expect(result?.Value).toEqual('test-value-3'); + }); + + describe('three pages of exports', () => { + let lookup: LazyLookupExport; + beforeEach(() => { + lookup = new LazyLookupExport(mockSdk); + listExports + .mockReset() + .mockReturnValueOnce({ + Exports: [ + createExport(1), + createExport(2), + createExport(3), + ], + NextToken: 'next-token-1', + }) + .mockReturnValueOnce({ + Exports: [ + createExport(4), + createExport(5), + createExport(6), + ], + NextToken: 'next-token-2', + }) + .mockReturnValueOnce({ + Exports: [ + createExport(7), + createExport(8), + ], + NextToken: undefined, + }); + }); + + it('returns the matching export', async () => { + const name = 'test-name-3'; + const result = await lookup.lookupExport(name); + expect(result?.Name).toEqual(name); + expect(result?.Value).toEqual('test-value-3'); + }); + + it('stops fetching once export is found', async () => { + await lookup.lookupExport('test-name-3'); + expect(listExports).toHaveBeenCalledTimes(1); + }); + + it('paginates', async () => { + await lookup.lookupExport('test-name-7'); + expect(listExports).toHaveBeenCalledTimes(3); + expect(listExports).toHaveBeenCalledWith({ + NextToken: 'next-token-1', + }); + expect(listExports).toHaveBeenCalledWith({ + NextToken: 'next-token-2', + }); + }); + + it('caches the calls to CloudFormation API', async () => { + await lookup.lookupExport('test-name-3'); + await lookup.lookupExport('test-name-3'); + await lookup.lookupExport('test-name-3'); + expect(listExports).toHaveBeenCalledTimes(1); + }); + + it('returns undefined if the export does not exist', async () => { + const result = await lookup.lookupExport('test-name-unknown'); + expect(result).toBeUndefined(); + }); + }); +});