diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts index 80b52f57f..7c63a0a77 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts @@ -1,9 +1,10 @@ import type { CloudFormationStackArtifact } from '@aws-cdk/cloud-assembly-api'; import type { Export, ListExportsCommandOutput, StackResourceSummary } from '@aws-sdk/client-cloudformation'; import type { NestedStackTemplates } from './nested-stack-helpers'; +import type { SharedLookups } from './shared-lookups'; import type { Template } from './stack-helpers'; import { ToolkitError } from '../../toolkit/toolkit-error'; -import type { ICloudControlClient, SDK } from '../aws-auth/private'; +import type { ICloudControlClient, ICloudFormationClient, SDK } from '../aws-auth/private'; import type { ResourceMetadata } from '../resource-metadata'; import { resourceMetadata } from '../resource-metadata'; @@ -82,6 +83,20 @@ export class LazyLookupExport implements LookupExport { export class CfnEvaluationException extends Error { } +/** + * The template uses an intrinsic function the evaluator does not support + */ +export class CfnUnsupportedFunctionException extends CfnEvaluationException { + constructor(public readonly functionName: string) { + super(`CloudFormation function ${functionName} is not supported`); + } +} + +/** Whether a looked-up value counts as found: like CloudFormation, an empty string (or `0`, `false`) is a value */ +function isValue(value: T | undefined | null): value is T { + return value !== undefined && value !== null; +} + function assertEvaluatesToArray(intrinsic: string, value: any): asserts value is any[] { if (!Array.isArray(value)) { throw new CfnEvaluationException(`${intrinsic}: expected an array, got ${JSON.stringify(value)}`); @@ -136,6 +151,24 @@ export interface EvaluateCloudFormationTemplateProps { * @default - a new, empty map */ readonly stackResourcesByStackName?: Map; + + /** + * Parameters whose values must not be used, e.g. NoEcho parameters, whose deployed values are masked. + * + * A `Ref` to one of them fails instead of falling back to the parameter's `Default`. + * + * @default - all parameters can be used + */ + readonly unavailableParameters?: string[]; + + /** + * Read resources and nested stack outputs through these shared lookups: each resource is read once and the + * number of lookups in flight is limited across evaluators, and the outputs of nested stacks are the values + * CloudFormation reports for the deployed nested stack, rather than evaluated from its template. + * + * @default - every lookup calls Cloud Control, and nested stack outputs are evaluated from their templates + */ + readonly lookups?: SharedLookups; } export class EvaluateCloudFormationTemplate { @@ -156,7 +189,10 @@ export class EvaluateCloudFormationTemplate { private cachedUrlSuffix: string | undefined; private _cloudControl: ICloudControlClient | undefined; + private _cloudFormation: ICloudFormationClient | undefined; private readonly createOnlyPropertiesCache = new Map>(); + private readonly unavailableParameters: Set; + private readonly lookups: SharedLookups | undefined; constructor(props: EvaluateCloudFormationTemplateProps) { this.stackArtifact = props.stackArtifact; @@ -190,6 +226,16 @@ export class EvaluateCloudFormationTemplate { // CloudFormation Exports lookup to be able to resolve Fn::ImportValue intrinsics in template this.lookupExport = new LazyLookupExport(this.sdk); + + this.unavailableParameters = new Set(props.unavailableParameters ?? []); + this.lookups = props.lookups; + } + + private get cloudFormation(): ICloudFormationClient { + if (!this._cloudFormation) { + this._cloudFormation = this.sdk.cloudFormation(); + } + return this._cloudFormation; } private get cloudControl(): ICloudControlClient { @@ -217,6 +263,8 @@ export class EvaluateCloudFormationTemplate { sdk: this.sdk, nestedStacks: this.nestedStacks, stackResourcesByStackName: this.stackResourcesByStackName, + // the parent's unavailable parameters can't reach the nested stack: evaluating its parameters above fails first + lookups: this.lookups, }); } @@ -275,7 +323,7 @@ export class EvaluateCloudFormationTemplate { public evaluateIntrinsic(intrinsic: Intrinsic): any { const intrinsicFunc = (this as any)[intrinsic.name]; if (!intrinsicFunc) { - throw new CfnEvaluationException(`CloudFormation function ${intrinsic.name} is not supported`); + throw new CfnUnsupportedFunctionException(intrinsic.name); } const argsAsArray = Array.isArray(intrinsic.args) ? intrinsic.args : [intrinsic.args]; @@ -295,15 +343,24 @@ export class EvaluateCloudFormationTemplate { return evaluatedArgs.split(separator); } - async 'Fn::Select'(index: number, args: any[]): Promise { + async 'Fn::Select'(index: any, args: any[]): Promise { + // the index can be an expression (Ref, Fn::FindInMap), and a parameter's value is a string + const evaluatedIndex = await self.evaluateCfnExpression(index); + const i = typeof evaluatedIndex === 'string' && evaluatedIndex.trim() !== '' ? Number(evaluatedIndex) : evaluatedIndex; + if (!Number.isInteger(i)) { + throw new CfnEvaluationException(`Fn::Select: expected an integer index, got ${JSON.stringify(evaluatedIndex)}`); + } const evaluatedArgs = await self.evaluateCfnExpression(args); assertEvaluatesToArray('Fn::Select', evaluatedArgs); - return evaluatedArgs[index]; + if (i < 0 || i >= evaluatedArgs.length) { + throw new CfnEvaluationException(`Fn::Select: index ${i} is out of range for a list of ${evaluatedArgs.length}`); + } + return evaluatedArgs[i]; } async Ref(logicalId: string): Promise { const refTarget = await self.findRefTarget(logicalId); - if (refTarget) { + if (isValue(refTarget)) { return refTarget; } else { throw new CfnEvaluationException(`Parameter or resource '${logicalId}' could not be found for evaluation`); @@ -313,7 +370,7 @@ export class EvaluateCloudFormationTemplate { async 'Fn::GetAtt'(logicalId: string, attributeName: string): Promise { // ToDo handle the 'logicalId.attributeName' form of Fn::GetAtt const attrValue = await self.findGetAttTarget(logicalId, attributeName); - if (attrValue) { + if (isValue(attrValue)) { return attrValue; } else { throw new CfnEvaluationException( @@ -326,6 +383,9 @@ export class EvaluateCloudFormationTemplate { const placeholders = explicitPlaceholders ? await self.evaluateCfnExpression(explicitPlaceholders) : {}; return asyncGlobalReplace(template, /\${([^}]*)}/g, (key) => { + if (key.startsWith('!')) { + return `\${${key.slice(1)}}`; // ${!Literal} is the literal text ${Literal} + } if (key in placeholders) { return placeholders[key]; } else { @@ -358,7 +418,7 @@ export class EvaluateCloudFormationTemplate { } if (typeof cfnExpression === 'object') { - const intrinsic = this.parseIntrinsic(cfnExpression); + const intrinsic = parseIntrinsic(cfnExpression); if (intrinsic) { return new CfnIntrinsics().evaluateIntrinsic(intrinsic); } else { @@ -484,17 +544,6 @@ export class EvaluateCloudFormationTemplate { return false; } - private parseIntrinsic(x: any): Intrinsic | undefined { - const keys = Object.keys(x); - if (keys.length === 1 && (keys[0].startsWith('Fn::') || keys[0] === 'Ref')) { - return { - name: keys[0], - args: x[keys[0]], - }; - } - return undefined; - } - private async findRefTarget(logicalId: string): Promise { // first, check to see if the Ref is a Parameter who's value we have if (logicalId === 'AWS::URLSuffix') { @@ -505,15 +554,19 @@ export class EvaluateCloudFormationTemplate { return this.cachedUrlSuffix; } + if (this.unavailableParameters.has(logicalId)) { + throw new CfnEvaluationException(`Parameter '${logicalId}' can't be used: its deployed value is not available`); + } + // Try finding the ref in the passed in parameters const parameterTarget = this.context[logicalId]; - if (parameterTarget) { + if (isValue(parameterTarget)) { return parameterTarget; } // If not in the passed in parameters, see if there is a default value in the template parameter that was not passed in const defaultParameterValue = this.template.Parameters?.[logicalId]?.Default; - if (defaultParameterValue) { + if (isValue(defaultParameterValue)) { return defaultParameterValue; } @@ -525,7 +578,7 @@ export class EvaluateCloudFormationTemplate { // Handle case where the attribute is referencing a stack output (used in nested stacks to share parameters) // See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-cloudformation.html#w2ab1c17c23c19b5 if (logicalId === 'Outputs' && attribute) { - return this.evaluateCfnExpression(this.template.Outputs[attribute]?.Value); + return this.evaluateCfnExpression(this.template.Outputs?.[attribute]?.Value); } const stackResources = await this.stackResources.listStackResources(); @@ -535,6 +588,15 @@ export class EvaluateCloudFormationTemplate { } if (foundResource.ResourceType == 'AWS::CloudFormation::Stack' && attribute?.startsWith('Outputs.')) { + if (this.lookups) { + if (!foundResource.PhysicalResourceId) { + // the nested stack has not been created (yet) + return undefined; + } + // the outputs of the deployed nested stack (CDK-managed or not), by its physical id (the stack's ARN) + const outputs = await this.lookups.deployedStackOutputs(this.cloudFormation, `${this.account}/${this.region}`, foundResource.PhysicalResourceId); + return outputs[attribute.slice('Outputs.'.length)]; + } const dependantStack = this.findNestedStack(logicalId, this.nestedStacks); if (!dependantStack || !dependantStack.physicalName) { // this is a newly created nested stack and cannot be hotswapped @@ -603,18 +665,23 @@ export class EvaluateCloudFormationTemplate { try { const identifier = await this.evaluateCloudControlIdentifier(resource.LogicalResourceId!, resource.ResourceType!, physicalId!); - const response = await this.cloudControl.getResource({ - TypeName: resource.ResourceType!, - Identifier: identifier, - }); - const props = JSON.parse(response.ResourceDescription?.Properties ?? '{}'); + const props = this.lookups + ? await this.lookups.resourceProperties(this.cloudControl, `${this.account}/${this.region}`, resource.ResourceType!, identifier) + : JSON.parse((await this.cloudControl.getResource({ + TypeName: resource.ResourceType!, + Identifier: identifier, + })).ResourceDescription?.Properties ?? '{}'); if (attribute in props) { return props[attribute]; } } catch (error: any) { + // The identifier could not be evaluated: report that failure itself, rather than as a failed lookup + if (error instanceof CfnEvaluationException) { + throw error; + } // Cloud Control lookup failed — fall through to the error below throw new CfnEvaluationException(`Could not find '${attribute}' attribute of the '${resource.ResourceType}' resource because` + - `an error occured while attempting to retrieve the information: '${error.name}:${error.message}'`); + `an error occured while attempting to retrieve the information: '${error.name}:${error.message}'`, { cause: error }); } throw new CfnEvaluationException( @@ -796,11 +863,25 @@ function appsyncGraphQlDataSourceNameFmt(parts: ArnParts): string { return parts.resourceName.split('/')[3]; } -interface Intrinsic { +export interface Intrinsic { readonly name: string; readonly args: any; } +/** + * The intrinsic function an object is, if it is one: `{ "Ref": ... }` or `{ "Fn::": ... }` + */ +export function parseIntrinsic(x: any): Intrinsic | undefined { + const keys = Object.keys(x); + if (keys.length === 1 && (keys[0].startsWith('Fn::') || keys[0] === 'Ref')) { + return { + name: keys[0], + args: x[keys[0]], + }; + } + return undefined; +} + async function asyncGlobalReplace(str: string, regex: RegExp, cb: (x: string) => Promise): Promise { if (!regex.global) { throw new ToolkitError('RegexMissingGlobalFlag', 'Regex must be created with /g flag'); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/index.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/index.ts index 3ddb88b6a..e7b5761e0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/index.ts @@ -3,3 +3,4 @@ export * from './template-body-parameter'; export * from './nested-stack-helpers'; export * from './stack-helpers'; export * from './logical-id-map'; +export * from './shared-lookups'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/shared-lookups.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/shared-lookups.ts new file mode 100644 index 000000000..a8ac132a0 --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/shared-lookups.ts @@ -0,0 +1,52 @@ +import { CloudFormationStack } from './stack-helpers'; +import { pLimit } from '../../util/concurrency'; +import type { ICloudControlClient, ICloudFormationClient } from '../aws-auth/private'; + +/** + * Lookups of deployed resources and stacks, shared by all the evaluators of one evaluation run + * + * Each resource and stack is read once, and at most `concurrency` lookups run at the same time across all + * evaluators, however many stacks and nested stacks are evaluated in parallel. A failed lookup is not remembered. + */ +export class SharedLookups { + private readonly limit: ReturnType; + private readonly resources = new Map>(); + private readonly stackOutputs = new Map>(); + + public constructor(concurrency: number = 10) { + this.limit = pLimit(concurrency); + } + + /** + * The properties of a resource, read through Cloud Control + * + * @param scope - account and region of the client, so the same identifier in two environments isn't shared + */ + public resourceProperties(cloudControl: ICloudControlClient, scope: string, typeName: string, identifier: string) { + return this.once(this.resources, JSON.stringify([scope, typeName, identifier]), async () => { + const response = await cloudControl.getResource({ TypeName: typeName, Identifier: identifier }); + return JSON.parse(response.ResourceDescription?.Properties ?? '{}'); + }); + } + + /** + * The outputs of a deployed stack, as CloudFormation reports them + */ + public deployedStackOutputs(cfn: ICloudFormationClient, scope: string, stackName: string) { + return this.once(this.stackOutputs, JSON.stringify([scope, stackName]), async () => { + return (await CloudFormationStack.lookup(cfn, stackName)).outputs; + }); + } + + private once(cache: Map>, key: string, lookup: () => Promise): Promise { + let result = cache.get(key); + if (!result) { + result = this.limit(lookup).catch((e) => { + cache.delete(key); + throw e; + }); + cache.set(key, result); + } + return result; + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts index 2e761435e..ecbd29e8c 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-cloudformation-template.test.ts @@ -1,9 +1,10 @@ import { GetResourceCommand } from '@aws-sdk/client-cloudcontrol'; -import { ListExportsCommand, ListStackResourcesCommand } from '@aws-sdk/client-cloudformation'; +import { DescribeStacksCommand, ListExportsCommand, ListStackResourcesCommand, StackStatus } from '@aws-sdk/client-cloudformation'; import type { Template } from '../../../lib/api/cloudformation'; import { CfnEvaluationException, EvaluateCloudFormationTemplate, + SharedLookups, } from '../../../lib/api/cloudformation'; import { MockSdk, mockCloudControlClient, mockCloudFormationClient, restoreSdkMocksToDefault } from '../../_helpers/mock-sdk'; @@ -442,3 +443,375 @@ describe('evaluateCfnExpression', () => { }); }); }); + +describe('parameter values', () => { + const template: Template = { + Parameters: { Suffix: { Type: 'String', Default: 'default-suffix' }, Blank: { Type: 'String', Default: '' } }, + }; + const evaluator = (parameters: { [name: string]: string }, extra: object = {}) => new EvaluateCloudFormationTemplate({ + template, + stackName: 'test-stack', + parameters, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + ...extra, + }); + + test('an empty string deployed value is used, not the default', async () => { + expect(await evaluator({ Suffix: '' }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual(''); + }); + + test('an empty string default is used', async () => { + expect(await evaluator({}).evaluateCfnExpression({ Ref: 'Blank' })).toEqual(''); + }); + + test('an unavailable parameter is not replaced by its default, including inside other functions', async () => { + const evaluateCfnTemplate = evaluator({}, { unavailableParameters: ['Suffix'] }); + + await expect(evaluateCfnTemplate.evaluateCfnExpression({ Ref: 'Suffix' })) + .rejects.toThrow("Parameter 'Suffix' can't be used: its deployed value is not available"); + await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Sub': 'name-${Suffix}' })).rejects.toThrow(CfnEvaluationException); + }); +}); + +describe('intrinsic functions', () => { + const evaluateCfnTemplate = createEvaluateCloudFormationTemplate({}); + + test('Fn::Select out of range is an evaluation failure', async () => { + await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Select': [2, ['a', 'b']] })) + .rejects.toThrow('Fn::Select: index 2 is out of range for a list of 2'); + }); + + test('Fn::Select evaluates its index, which can come from a parameter', async () => { + const evaluator = new EvaluateCloudFormationTemplate({ + template: { Parameters: { Idx: { Type: 'Number' } } }, + stackName: 'test-stack', + parameters: { Idx: '1' }, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + }); + + expect(await evaluator.evaluateCfnExpression({ 'Fn::Select': [{ Ref: 'Idx' }, ['a', 'b', 'c']] })).toEqual('b'); + }); + + test('Fn::Select with an index that is not an integer is an evaluation failure', async () => { + await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Select': [0.5, ['a', 'b']] })) + .rejects.toThrow('Fn::Select: expected an integer index, got 0.5'); + }); + + test('Fn::Sub ${!Literal} is the literal text ${Literal}', async () => { + expect(await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Sub': 'a-${!Literal}-b' })).toEqual('a-${Literal}-b'); + }); +}); + +describe('shared lookups', () => { + beforeEach(() => { + restoreSdkMocksToDefault(); + }); + + test('read each resource once, whatever attributes and evaluators ask for it', async () => { + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Topic', + PhysicalResourceId: 'arn:aws:sns:us-east-1:0123456789:topic', + ResourceType: 'AWS::SNS::Topic', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).resolves({ ResourceDescription: { Properties: JSON.stringify({ TopicName: 't', DisplayName: 'd' }) } }); + const lookups = new SharedLookups(); + const evaluator = () => new EvaluateCloudFormationTemplate({ + template: { Resources: { Topic: { Type: 'AWS::SNS::Topic' } } }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + lookups, + }); + + expect(await evaluator().evaluateCfnExpression({ 'Fn::GetAtt': ['Topic', 'TopicName'] })).toEqual('t'); + expect(await evaluator().evaluateCfnExpression({ 'Fn::GetAtt': ['Topic', 'DisplayName'] })).toEqual('d'); + expect(mockCloudControlClient).toHaveReceivedCommandTimes(GetResourceCommand, 1); + }); + + test('limit the number of lookups in flight', async () => { + let running = 0; + let maxRunning = 0; + const cloudControl = { + async getResource() { + running++; + maxRunning = Math.max(maxRunning, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running--; + return { ResourceDescription: { Properties: '{}' } }; + }, + } as any; + const lookups = new SharedLookups(3); + + // start them all at once; the limit decides how many run + const started = Array.from({ length: 10 }, (_, i) => lookups.resourceProperties(cloudControl, 'scope', 'AWS::SNS::Topic', `t${i}`)); + for (const lookup of started) { + await lookup; + } + + expect(maxRunning).toEqual(3); + }); + + test('a nested stack that has not been created has no outputs, rather than another stack\'s', async () => { + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Plain', + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_IN_PROGRESS', + LastUpdatedTimestamp: new Date(), + }], + }); + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + template: { Resources: { Plain: { Type: 'AWS::CloudFormation::Stack', Properties: { TemplateURL: 'https://bucket/t.json' } } } }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + lookups: new SharedLookups(), + }); + + await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Plain', 'Outputs.Endpoint'] })) + .rejects.toThrow(CfnEvaluationException); + // without a stack name, DescribeStacks would list every stack in the account + expect(mockCloudFormationClient).not.toHaveReceivedCommand(DescribeStacksCommand); + }); + + test('read the outputs of a nested stack that is not CDK-managed, by its physical id', async () => { + const nestedArn = 'arn:aws:cloudformation:us-east-1:0123456789:stack/plain-nested/1234'; + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Plain', + PhysicalResourceId: nestedArn, + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudFormationClient.on(DescribeStacksCommand, { StackName: nestedArn }).resolves({ + Stacks: [{ + StackName: 'plain-nested', + StackStatus: StackStatus.CREATE_COMPLETE, + CreationTime: new Date(), + Outputs: [{ OutputKey: 'Endpoint', OutputValue: 'https://example.com' }], + }], + }); + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + template: { Resources: { Plain: { Type: 'AWS::CloudFormation::Stack', Properties: { TemplateURL: 'https://bucket/t.json' } } } }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + lookups: new SharedLookups(), + }); + + expect(await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Plain', 'Outputs.Endpoint'] })).toEqual('https://example.com'); + }); + + test('read nested stack outputs from the deployed nested stack', async () => { + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Nested', + PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:0123456789:stack/test-stack-Nested-ABC/1234', + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudFormationClient.on(DescribeStacksCommand, { StackName: 'arn:aws:cloudformation:us-east-1:0123456789:stack/test-stack-Nested-ABC/1234' }).resolves({ + Stacks: [{ + StackName: 'test-stack-Nested-ABC', + StackStatus: StackStatus.CREATE_COMPLETE, + CreationTime: new Date(), + Outputs: [{ OutputKey: 'Name', OutputValue: 'deployed-name' }], + }], + }); + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + template: { Resources: { Nested: { Type: 'AWS::CloudFormation::Stack', Properties: {} } } }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + nestedStacks: { + // an output the template can't evaluate + Nested: { + physicalName: 'test-stack-Nested-ABC', + deployedTemplate: {}, + nestedStackTemplates: {}, + generatedTemplate: { Outputs: { Name: { Value: { 'Fn::GetAZs': '' } } } }, + }, + }, + lookups: new SharedLookups(), + }); + + expect(await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Nested', 'Outputs.Name'] })).toEqual('deployed-name'); + }); +}); + +describe('nested stack outputs', () => { + test('a nested template without Outputs is an evaluation failure, not a crash', async () => { + restoreSdkMocksToDefault(); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Nested', + PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:0123456789:stack/test-stack-Nested-ABC/1234', + ResourceType: 'AWS::CloudFormation::Stack', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + template: { Resources: { Nested: { Type: 'AWS::CloudFormation::Stack', Properties: {} } } }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + nestedStacks: { + Nested: { physicalName: 'test-stack-Nested-ABC', generatedTemplate: { Resources: {} }, deployedTemplate: {}, nestedStackTemplates: {} }, + }, + }); + + await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Nested', 'Outputs.Missing'] })) + .rejects.toThrow(CfnEvaluationException); + }); +}); + +describe('attribute values', () => { + beforeEach(() => { + restoreSdkMocksToDefault(); + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Topic', + PhysicalResourceId: 'arn:aws:sns:us-east-1:0123456789:topic', + ResourceType: 'AWS::SNS::Topic', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + mockCloudControlClient.on(GetResourceCommand).resolves({ ResourceDescription: { Properties: JSON.stringify({ TopicName: null }) } }); + }); + + test('a null attribute is not a value', async () => { + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + template: { Resources: { Topic: { Type: 'AWS::SNS::Topic' } } }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + }); + + await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Topic', 'TopicName'] })) + .rejects.toThrow("Attribute 'TopicName' of resource 'Topic' could not be found for evaluation"); + }); +}); + +describe('failed Cloud Control lookups', () => { + beforeEach(() => { + restoreSdkMocksToDefault(); + }); + + test('keep the original error as the cause', async () => { + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'Topic', + PhysicalResourceId: 'arn:aws:sns:us-east-1:0123456789:topic', + ResourceType: 'AWS::SNS::Topic', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + const throttled = Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException' }); + mockCloudControlClient.on(GetResourceCommand).rejects(throttled); + const evaluateCfnTemplate = createEvaluateCloudFormationTemplate({ Resources: { Topic: { Type: 'AWS::SNS::Topic' } } }); + + const error = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Topic', 'TopicName'] }).catch((e) => e); + + expect(error).toBeInstanceOf(CfnEvaluationException); + expect(error.cause).toBe(throttled); + }); + + test('turn any other error while building the Cloud Control identifier into an evaluation failure', async () => { + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'User', + PhysicalResourceId: 'some-user', + ResourceType: 'AWS::Cognito::UserPoolUser', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + const evaluateCfnTemplate = createEvaluateCloudFormationTemplate({ + Resources: { User: { Type: 'AWS::Cognito::UserPoolUser', Properties: { UserPoolId: { 'Fn::ImportValue': 'PoolExport' } } } }, + }); + const throttled = Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException' }); + mockCloudFormationClient.on(ListExportsCommand).rejects(throttled); + + const error = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['User', 'Username'] }).catch((e) => e); + + expect(error).toBeInstanceOf(CfnEvaluationException); + expect(error.cause).toBe(throttled); + }); + + test('report a Cloud Control identifier that cannot be evaluated as it is, not as a failed lookup', async () => { + mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ + StackResourceSummaries: [{ + LogicalResourceId: 'User', + PhysicalResourceId: 'some-user', + ResourceType: 'AWS::Cognito::UserPoolUser', + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }], + }); + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + template: { + Parameters: { Pool: { Type: 'String', NoEcho: true, Default: 'not-the-deployed-value' } }, + Resources: { User: { Type: 'AWS::Cognito::UserPoolUser', Properties: { UserPoolId: { Ref: 'Pool' } } } }, + }, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + unavailableParameters: ['Pool'], + }); + + const error = await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['User', 'Username'] }).catch((e) => e); + + expect(error).toBeInstanceOf(CfnEvaluationException); + expect(error.message).toEqual("Parameter 'Pool' can't be used: its deployed value is not available"); + expect(error.cause).toBeUndefined(); + expect(mockCloudControlClient).not.toHaveReceivedCommand(GetResourceCommand); + }); +}); +