From 3ecddced294bdbb2850eaf8ac6cbe11f1c6fcc8d Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:21:56 -0400 Subject: [PATCH 1/4] fix(toolkit-lib): stricter, opt-in evaluation modes for EvaluateCloudFormationTemplate Opt-in props, off for hotswap: - strictValues: CloudFormation value semantics (empty strings are values, null is missing), Fn::Select out of range is an evaluation failure, Fn::Sub handles ${!Literal} - unavailableParameters: a Ref to one fails instead of falling back to its Default - lookups (SharedLookups): Cloud Control reads cached per resource and limited across evaluators, nested stack outputs read from DescribeStacks Changes that also apply to hotswap (error handling only): - unsupported functions throw CfnUnsupportedFunctionException (a CfnEvaluationException, same message) - failed Cloud Control lookups keep the original error as the cause - a Cloud Control identifier that can't be evaluated throws its own evaluation error; other errors while building it are still wrapped in a CfnEvaluationException - Fn::GetAtt of a nested stack output when the nested template has no Outputs is an evaluation failure, not a TypeError - parseIntrinsic is exported for reuse --- .../evaluate-cloudformation-template.ts | 128 +++++-- .../lib/api/cloudformation/index.ts | 1 + .../lib/api/cloudformation/shared-lookups.ts | 52 +++ .../evaluate-cloudformation-template.test.ts | 347 +++++++++++++++++- 4 files changed, 502 insertions(+), 26 deletions(-) create mode 100644 packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/shared-lookups.ts 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..8da2b9f15 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,6 +1,7 @@ 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'; @@ -82,6 +83,15 @@ 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`); + } +} + 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 +146,36 @@ export interface EvaluateCloudFormationTemplateProps { * @default - a new, empty map */ readonly stackResourcesByStackName?: Map; + + /** + * Treat values the way CloudFormation does: an empty string (or `0`, `false`) is a value, + * and only a missing or `null` value is missing. + * + * Hotswap leaves this off, and treats every falsy value as missing. + * + * @default false + */ + readonly strictValues?: boolean; + + /** + * 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. + * + * Hotswap leaves this off. + * + * @default - every lookup calls Cloud Control, and nested stack outputs are evaluated from their templates + */ + readonly lookups?: SharedLookups; } export class EvaluateCloudFormationTemplate { @@ -157,6 +197,9 @@ export class EvaluateCloudFormationTemplate { private cachedUrlSuffix: string | undefined; private _cloudControl: ICloudControlClient | undefined; private readonly createOnlyPropertiesCache = new Map>(); + private readonly strictValues: boolean; + private readonly unavailableParameters: Set; + private readonly lookups: SharedLookups | undefined; constructor(props: EvaluateCloudFormationTemplateProps) { this.stackArtifact = props.stackArtifact; @@ -190,6 +233,15 @@ export class EvaluateCloudFormationTemplate { // CloudFormation Exports lookup to be able to resolve Fn::ImportValue intrinsics in template this.lookupExport = new LazyLookupExport(this.sdk); + + this.strictValues = props.strictValues ?? false; + this.unavailableParameters = new Set(props.unavailableParameters ?? []); + this.lookups = props.lookups; + } + + /** Whether a looked-up value counts as found */ + private isValue(value: T | undefined | null): value is T { + return this.strictValues ? value !== undefined && value !== null : !!value; } private get cloudControl(): ICloudControlClient { @@ -217,6 +269,9 @@ 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 + strictValues: this.strictValues, + lookups: this.lookups, }); } @@ -275,7 +330,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]; @@ -298,12 +353,15 @@ export class EvaluateCloudFormationTemplate { async 'Fn::Select'(index: number, args: any[]): Promise { const evaluatedArgs = await self.evaluateCfnExpression(args); assertEvaluatesToArray('Fn::Select', evaluatedArgs); + if (self.strictValues && !(index >= 0 && index < evaluatedArgs.length)) { + throw new CfnEvaluationException(`Fn::Select: index ${index} is out of range for a list of ${evaluatedArgs.length}`); + } return evaluatedArgs[index]; } async Ref(logicalId: string): Promise { const refTarget = await self.findRefTarget(logicalId); - if (refTarget) { + if (self.isValue(refTarget)) { return refTarget; } else { throw new CfnEvaluationException(`Parameter or resource '${logicalId}' could not be found for evaluation`); @@ -313,7 +371,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 (self.isValue(attrValue)) { return attrValue; } else { throw new CfnEvaluationException( @@ -326,6 +384,9 @@ export class EvaluateCloudFormationTemplate { const placeholders = explicitPlaceholders ? await self.evaluateCfnExpression(explicitPlaceholders) : {}; return asyncGlobalReplace(template, /\${([^}]*)}/g, (key) => { + if (self.strictValues && key.startsWith('!')) { + return `\${${key.slice(1)}}`; // ${!Literal} is the literal text ${Literal} + } if (key in placeholders) { return placeholders[key]; } else { @@ -358,7 +419,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 +545,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 +555,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 (this.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 (this.isValue(defaultParameterValue)) { return defaultParameterValue; } @@ -525,7 +579,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(); @@ -534,6 +588,11 @@ export class EvaluateCloudFormationTemplate { return undefined; } + if (foundResource.ResourceType == 'AWS::CloudFormation::Stack' && attribute?.startsWith('Outputs.') && this.lookups) { + // 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.sdk.cloudFormation(), `${this.account}/${this.region}`, foundResource.PhysicalResourceId!); + return outputs[attribute.slice('Outputs.'.length)]; + } if (foundResource.ResourceType == 'AWS::CloudFormation::Stack' && attribute?.startsWith('Outputs.')) { const dependantStack = this.findNestedStack(logicalId, this.nestedStacks); if (!dependantStack || !dependantStack.physicalName) { @@ -603,18 +662,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 +860,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..1d82013a7 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,347 @@ 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('by default, an empty string deployed value falls back to the default (hotswap behaviour)', async () => { + expect(await evaluator({ Suffix: '' }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual('default-suffix'); + }); + + test('with strictValues, an empty string deployed value is used, not the default', async () => { + expect(await evaluator({ Suffix: '' }, { strictValues: true }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual(''); + }); + + test('with strictValues, an empty string default is used', async () => { + expect(await evaluator({}, { strictValues: true }).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('strictValues', () => { + const evaluator = (strictValues: boolean) => new EvaluateCloudFormationTemplate({ + template: {}, + stackName: 'test-stack', + parameters: {}, + account: '0123456789', + region: 'ap-south-east-2', + partition: 'aws', + sdk, + stackArtifact: {} as any, + strictValues, + }); + + test('Fn::Select out of range is an evaluation failure with strictValues, and undefined without (hotswap behaviour)', async () => { + const expr = { 'Fn::Select': [2, ['a', 'b']] }; + expect(await evaluator(false).evaluateCfnExpression(expr)).toBeUndefined(); + await expect(evaluator(true).evaluateCfnExpression(expr)).rejects.toThrow('Fn::Select: index 2 is out of range for a list of 2'); + }); + + test('Fn::Sub ${!Literal} is the literal text with strictValues, and a reference without (hotswap behaviour)', async () => { + const expr = { 'Fn::Sub': 'a-${!Literal}-b' }; + expect(await evaluator(true).evaluateCfnExpression(expr)).toEqual('a-${Literal}-b'); + await expect(evaluator(false).evaluateCfnExpression(expr)).rejects.toThrow(CfnEvaluationException); + }); +}); + +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('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.each([false, true])('a null attribute is not a value (strictValues: %s)', async (strictValues) => { + 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, + strictValues, + }); + + 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 () => { + // hotswap relies on this: a CfnEvaluationException makes the change non-hotswappable, anything else fails the deployment + 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); + }); +}); + From ab978ee381b0c49bced835ab4f1f16398192bed7 Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:43:04 -0400 Subject: [PATCH 2/4] docs(toolkit-lib): describe the evaluator options without referring to their callers --- .../evaluate-cloudformation-template.ts | 12 ++++++------ .../evaluate-cloudformation-template.test.ts | 7 +++---- 2 files changed, 9 insertions(+), 10 deletions(-) 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 8da2b9f15..055133da4 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 @@ -148,12 +148,14 @@ export interface EvaluateCloudFormationTemplateProps { readonly stackResourcesByStackName?: Map; /** - * Treat values the way CloudFormation does: an empty string (or `0`, `false`) is a value, - * and only a missing or `null` value is missing. + * Evaluate values the way CloudFormation does * - * Hotswap leaves this off, and treats every falsy value as missing. + * - an empty string (or `0`, `false`) is a value, and only a missing or `null` value is missing + * - `Fn::Select` with an index out of range is an evaluation failure + * - `${!Literal}` in `Fn::Sub` is the literal text `${Literal}` * - * @default false + * @default false - every falsy value is treated as missing, `Fn::Select` out of range evaluates to `undefined`, + * and `${!Literal}` is evaluated as a reference */ readonly strictValues?: boolean; @@ -171,8 +173,6 @@ export interface EvaluateCloudFormationTemplateProps { * 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. * - * Hotswap leaves this off. - * * @default - every lookup calls Cloud Control, and nested stack outputs are evaluated from their templates */ readonly lookups?: SharedLookups; 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 1d82013a7..3e5345605 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 @@ -460,7 +460,7 @@ describe('parameter values', () => { ...extra, }); - test('by default, an empty string deployed value falls back to the default (hotswap behaviour)', async () => { + test('by default, an empty string deployed value falls back to the default', async () => { expect(await evaluator({ Suffix: '' }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual('default-suffix'); }); @@ -494,13 +494,13 @@ describe('strictValues', () => { strictValues, }); - test('Fn::Select out of range is an evaluation failure with strictValues, and undefined without (hotswap behaviour)', async () => { + test('Fn::Select out of range is an evaluation failure with strictValues, and undefined without', async () => { const expr = { 'Fn::Select': [2, ['a', 'b']] }; expect(await evaluator(false).evaluateCfnExpression(expr)).toBeUndefined(); await expect(evaluator(true).evaluateCfnExpression(expr)).rejects.toThrow('Fn::Select: index 2 is out of range for a list of 2'); }); - test('Fn::Sub ${!Literal} is the literal text with strictValues, and a reference without (hotswap behaviour)', async () => { + test('Fn::Sub ${!Literal} is the literal text with strictValues, and a reference without', async () => { const expr = { 'Fn::Sub': 'a-${!Literal}-b' }; expect(await evaluator(true).evaluateCfnExpression(expr)).toEqual('a-${Literal}-b'); await expect(evaluator(false).evaluateCfnExpression(expr)).rejects.toThrow(CfnEvaluationException); @@ -731,7 +731,6 @@ describe('failed Cloud Control lookups', () => { }); test('turn any other error while building the Cloud Control identifier into an evaluation failure', async () => { - // hotswap relies on this: a CfnEvaluationException makes the change non-hotswappable, anything else fails the deployment mockCloudFormationClient.on(ListStackResourcesCommand).resolves({ StackResourceSummaries: [{ LogicalResourceId: 'User', From 34a1a4081574c8893e724c831222f9f706b1bd61 Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:04:19 -0400 Subject: [PATCH 3/4] fix(toolkit-lib): evaluate values the way CloudFormation does, always Remove the strictValues option and make its semantics the default: - an empty string (or 0, false) is a value; only a missing or null value is missing - Fn::Select with an index out of range is an evaluation failure - ${!Literal} in Fn::Sub is the literal text ${Literal} For hotswap, each of these either replaces a wrong value with the correct one (an empty-string parameter no longer falls back to its Default; Fn::Select out of range no longer becomes an empty string) or turns a case that fell back to a full deployment into a hotswap with the correct value ("", 0 or false attributes, ${!Literal}). A null attribute is still missing. --- .../evaluate-cloudformation-template.ts | 23 ++-------- .../evaluate-cloudformation-template.test.ts | 42 ++++++------------- 2 files changed, 16 insertions(+), 49 deletions(-) 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 055133da4..540977166 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 @@ -147,18 +147,6 @@ export interface EvaluateCloudFormationTemplateProps { */ readonly stackResourcesByStackName?: Map; - /** - * Evaluate values the way CloudFormation does - * - * - an empty string (or `0`, `false`) is a value, and only a missing or `null` value is missing - * - `Fn::Select` with an index out of range is an evaluation failure - * - `${!Literal}` in `Fn::Sub` is the literal text `${Literal}` - * - * @default false - every falsy value is treated as missing, `Fn::Select` out of range evaluates to `undefined`, - * and `${!Literal}` is evaluated as a reference - */ - readonly strictValues?: boolean; - /** * Parameters whose values must not be used, e.g. NoEcho parameters, whose deployed values are masked. * @@ -197,7 +185,6 @@ export class EvaluateCloudFormationTemplate { private cachedUrlSuffix: string | undefined; private _cloudControl: ICloudControlClient | undefined; private readonly createOnlyPropertiesCache = new Map>(); - private readonly strictValues: boolean; private readonly unavailableParameters: Set; private readonly lookups: SharedLookups | undefined; @@ -234,14 +221,13 @@ export class EvaluateCloudFormationTemplate { // CloudFormation Exports lookup to be able to resolve Fn::ImportValue intrinsics in template this.lookupExport = new LazyLookupExport(this.sdk); - this.strictValues = props.strictValues ?? false; this.unavailableParameters = new Set(props.unavailableParameters ?? []); this.lookups = props.lookups; } - /** Whether a looked-up value counts as found */ + /** Whether a looked-up value counts as found: like CloudFormation, an empty string (or `0`, `false`) is a value */ private isValue(value: T | undefined | null): value is T { - return this.strictValues ? value !== undefined && value !== null : !!value; + return value !== undefined && value !== null; } private get cloudControl(): ICloudControlClient { @@ -270,7 +256,6 @@ export class EvaluateCloudFormationTemplate { nestedStacks: this.nestedStacks, stackResourcesByStackName: this.stackResourcesByStackName, // the parent's unavailable parameters can't reach the nested stack: evaluating its parameters above fails first - strictValues: this.strictValues, lookups: this.lookups, }); } @@ -353,7 +338,7 @@ export class EvaluateCloudFormationTemplate { async 'Fn::Select'(index: number, args: any[]): Promise { const evaluatedArgs = await self.evaluateCfnExpression(args); assertEvaluatesToArray('Fn::Select', evaluatedArgs); - if (self.strictValues && !(index >= 0 && index < evaluatedArgs.length)) { + if (!(index >= 0 && index < evaluatedArgs.length)) { throw new CfnEvaluationException(`Fn::Select: index ${index} is out of range for a list of ${evaluatedArgs.length}`); } return evaluatedArgs[index]; @@ -384,7 +369,7 @@ export class EvaluateCloudFormationTemplate { const placeholders = explicitPlaceholders ? await self.evaluateCfnExpression(explicitPlaceholders) : {}; return asyncGlobalReplace(template, /\${([^}]*)}/g, (key) => { - if (self.strictValues && key.startsWith('!')) { + if (key.startsWith('!')) { return `\${${key.slice(1)}}`; // ${!Literal} is the literal text ${Literal} } if (key in placeholders) { 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 3e5345605..7c456c7ae 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 @@ -460,16 +460,12 @@ describe('parameter values', () => { ...extra, }); - test('by default, an empty string deployed value falls back to the default', async () => { - expect(await evaluator({ Suffix: '' }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual('default-suffix'); + test('an empty string deployed value is used, not the default', async () => { + expect(await evaluator({ Suffix: '' }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual(''); }); - test('with strictValues, an empty string deployed value is used, not the default', async () => { - expect(await evaluator({ Suffix: '' }, { strictValues: true }).evaluateCfnExpression({ Ref: 'Suffix' })).toEqual(''); - }); - - test('with strictValues, an empty string default is used', async () => { - expect(await evaluator({}, { strictValues: true }).evaluateCfnExpression({ Ref: 'Blank' })).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 () => { @@ -481,29 +477,16 @@ describe('parameter values', () => { }); }); -describe('strictValues', () => { - const evaluator = (strictValues: boolean) => new EvaluateCloudFormationTemplate({ - template: {}, - stackName: 'test-stack', - parameters: {}, - account: '0123456789', - region: 'ap-south-east-2', - partition: 'aws', - sdk, - stackArtifact: {} as any, - strictValues, - }); +describe('intrinsic functions', () => { + const evaluateCfnTemplate = createEvaluateCloudFormationTemplate({}); - test('Fn::Select out of range is an evaluation failure with strictValues, and undefined without', async () => { - const expr = { 'Fn::Select': [2, ['a', 'b']] }; - expect(await evaluator(false).evaluateCfnExpression(expr)).toBeUndefined(); - await expect(evaluator(true).evaluateCfnExpression(expr)).rejects.toThrow('Fn::Select: index 2 is out of range for a list of 2'); + 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::Sub ${!Literal} is the literal text with strictValues, and a reference without', async () => { - const expr = { 'Fn::Sub': 'a-${!Literal}-b' }; - expect(await evaluator(true).evaluateCfnExpression(expr)).toEqual('a-${Literal}-b'); - await expect(evaluator(false).evaluateCfnExpression(expr)).rejects.toThrow(CfnEvaluationException); + test('Fn::Sub ${!Literal} is the literal text ${Literal}', async () => { + expect(await evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::Sub': 'a-${!Literal}-b' })).toEqual('a-${Literal}-b'); }); }); @@ -687,7 +670,7 @@ describe('attribute values', () => { mockCloudControlClient.on(GetResourceCommand).resolves({ ResourceDescription: { Properties: JSON.stringify({ TopicName: null }) } }); }); - test.each([false, true])('a null attribute is not a value (strictValues: %s)', async (strictValues) => { + test('a null attribute is not a value', async () => { const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ template: { Resources: { Topic: { Type: 'AWS::SNS::Topic' } } }, stackName: 'test-stack', @@ -697,7 +680,6 @@ describe('attribute values', () => { partition: 'aws', sdk, stackArtifact: {} as any, - strictValues, }); await expect(evaluateCfnTemplate.evaluateCfnExpression({ 'Fn::GetAtt': ['Topic', 'TopicName'] })) From 6024868c5c1ef327608eb6cd182ccaa1e9377211 Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:32:14 -0400 Subject: [PATCH 4/4] fix(toolkit-lib): guard nested stack outputs without a physical id, and evaluate the Fn::Select index - reading a nested stack's outputs through shared lookups requires its physical id: a nested stack that has not been created is an evaluation failure, instead of DescribeStacks without a stack name (which lists every stack and returned the first one's outputs) - Fn::Select evaluates its index (Ref, Fn::FindInMap), accepts numeric strings, and requires an integer - create the CloudFormation client once per evaluator; one nested-stack-outputs branch; isValue is a module-level helper --- .../evaluate-cloudformation-template.ts | 52 +++++++++++++------ .../evaluate-cloudformation-template.test.ts | 47 +++++++++++++++++ 2 files changed, 82 insertions(+), 17 deletions(-) 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 540977166..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 @@ -4,7 +4,7 @@ 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'; @@ -92,6 +92,11 @@ export class CfnUnsupportedFunctionException extends CfnEvaluationException { } } +/** 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)}`); @@ -184,6 +189,7 @@ 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; @@ -225,9 +231,11 @@ export class EvaluateCloudFormationTemplate { this.lookups = props.lookups; } - /** Whether a looked-up value counts as found: like CloudFormation, an empty string (or `0`, `false`) is a value */ - private isValue(value: T | undefined | null): value is T { - return value !== undefined && value !== null; + private get cloudFormation(): ICloudFormationClient { + if (!this._cloudFormation) { + this._cloudFormation = this.sdk.cloudFormation(); + } + return this._cloudFormation; } private get cloudControl(): ICloudControlClient { @@ -335,18 +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); - if (!(index >= 0 && index < evaluatedArgs.length)) { - throw new CfnEvaluationException(`Fn::Select: index ${index} is out of range for a list of ${evaluatedArgs.length}`); + 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[index]; + return evaluatedArgs[i]; } async Ref(logicalId: string): Promise { const refTarget = await self.findRefTarget(logicalId); - if (self.isValue(refTarget)) { + if (isValue(refTarget)) { return refTarget; } else { throw new CfnEvaluationException(`Parameter or resource '${logicalId}' could not be found for evaluation`); @@ -356,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 (self.isValue(attrValue)) { + if (isValue(attrValue)) { return attrValue; } else { throw new CfnEvaluationException( @@ -546,13 +560,13 @@ export class EvaluateCloudFormationTemplate { // Try finding the ref in the passed in parameters const parameterTarget = this.context[logicalId]; - if (this.isValue(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 (this.isValue(defaultParameterValue)) { + if (isValue(defaultParameterValue)) { return defaultParameterValue; } @@ -573,12 +587,16 @@ export class EvaluateCloudFormationTemplate { return undefined; } - if (foundResource.ResourceType == 'AWS::CloudFormation::Stack' && attribute?.startsWith('Outputs.') && this.lookups) { - // 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.sdk.cloudFormation(), `${this.account}/${this.region}`, foundResource.PhysicalResourceId!); - return outputs[attribute.slice('Outputs.'.length)]; - } 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 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 7c456c7ae..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 @@ -485,6 +485,26 @@ describe('intrinsic functions', () => { .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'); }); @@ -547,6 +567,33 @@ describe('shared lookups', () => { 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({