From 5d2b1bede872ff5be4a60f4294e386322a405ba4 Mon Sep 17 00:00:00 2001 From: Ian Hou <45278651+iankhou@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:27:10 -0400 Subject: [PATCH] feat(toolkit-lib): best-effort evaluation of a whole template (internal) evaluateTemplateBestEffort evaluates every expression in a template's Resources and Outputs with EvaluateCloudFormationTemplate, one expression at a time, and reports the ones it can't evaluate instead of failing: - evaluates Conditions (Fn::Equals, And, Or, Not, Condition), Fn::If and AWS::NoValue, and drops resources and outputs whose condition is false - reports only the innermost failure, and unsupported functions themselves - reports expressions that use unavailable (e.g. NoEcho) parameters, and dynamic references - evaluates each distinct expression once, and looks up the reachable attributes up front, a few at a time - only evaluation failures are reported; other errors (expired credentials, throttling) are thrown Not used yet: the unstable evaluate action builds on it. --- .../evaluate-template-best-effort.ts | 407 ++++++++++++++++ .../lib/api/cloudformation/index.ts | 1 + .../evaluate-template-best-effort.test.ts | 433 ++++++++++++++++++ 3 files changed, 841 insertions(+) create mode 100644 packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts create mode 100644 packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-template-best-effort.test.ts diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts new file mode 100644 index 000000000..6c73410ae --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-template-best-effort.ts @@ -0,0 +1,407 @@ +import type { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; +import { CfnEvaluationException, CfnUnsupportedFunctionException, parseIntrinsic } from './evaluate-cloudformation-template'; +import type { Template } from './stack-helpers'; +import { formatErrorMessage, parallelPromises } from '../../util'; + +/** + * An expression in a template that could not be evaluated + */ +export interface UnresolvedExpression { + /** + * Location of the expression in the template, e.g. `Resources.MyBucket.Properties.BucketName` + */ + readonly path: string; + + /** + * The intrinsic function that could not be evaluated, e.g. `Fn::GetAtt` + */ + readonly intrinsic: string; + + /** + * Why the expression could not be evaluated + */ + readonly reason: string; +} + +/** + * A template with as many expressions as possible replaced by their values + */ +export interface BestEffortEvaluation { + /** + * The template, with every expression in `Resources` and `Outputs` that could be evaluated replaced by its value + */ + readonly template: Template; + + /** + * The expressions that could not be evaluated + */ + readonly unresolved: UnresolvedExpression[]; +} + +export interface BestEffortOptions { + /** + * Parameters whose values can't be used, with the reason (for example NoEcho parameters, whose deployed values + * CloudFormation masks). Expressions that reference them are reported instead of evaluated. + * + * @default - all parameter values can be used + */ + readonly unavailableParameters?: { [name: string]: string }; +} + +/** Attribute lookups to start at the same time, per template (calls are also limited by `SharedLookups`) */ +const PREFETCH_CONCURRENCY = 8; + +/** `{{resolve:ssm:...}}`, `{{resolve:secretsmanager:...}}`: resolved by CloudFormation at deployment */ +const DYNAMIC_REFERENCE = /\{\{resolve:[^}]*\}\}/; + +/** `{ "Ref": "AWS::NoValue" }`: the enclosing property or list element is removed */ +const NO_VALUE = Symbol('AWS::NoValue'); + +/** Unknown truth value of a condition that could not be evaluated */ +type Truth = boolean | undefined; + +/** + * Evaluates every expression in the `Resources` and `Outputs` of a template, one expression at a time. + * + * Expressions are evaluated with `EvaluateCloudFormationTemplate`. On top of it, this evaluates the template's + * `Conditions` (`Fn::Equals`, `Fn::And`, `Fn::Or`, `Fn::Not`, `Condition`), `Fn::If` and `AWS::NoValue`, and drops + * resources and outputs whose condition is false, like CloudFormation does. + * + * An expression that cannot be evaluated does not stop the rest: it is kept in place (with its arguments + * evaluated where possible) and reported. When an expression fails because one of its arguments failed, + * only the innermost failure is reported. Expressions that reference unavailable parameters (such as `NoEcho` + * parameters, whose deployed values CloudFormation masks) are reported rather than evaluated. + * + * Only evaluation failures are reported this way. Any other error (for example expired credentials, throttling, + * or a lookup that failed for a reason other than the resource or attribute not being found or readable) is thrown. + */ +export async function evaluateTemplateBestEffort( + evaluator: EvaluateCloudFormationTemplate, + template: Template, + options: BestEffortOptions = {}, +): Promise { + const unresolved: UnresolvedExpression[] = []; + const conditions = new Map>(); + const unavailable = options.unavailableParameters ?? {}; + const noEcho = new Set(Object.keys(unavailable)); + // The same expression has the same value everywhere in a stack: evaluate it once (saves repeated lookups). + // Only values and evaluation failures are kept; anything else is thrown and not remembered. + const evaluations = new Map>(); + const evaluateOnce = (node: any): Promise => { + const key = JSON.stringify(node); + if (!evaluations.has(key)) { + evaluations.set(key, evaluator.evaluateCfnExpression(node).catch((e) => { + if (!isUnresolvable(e)) { + evaluations.delete(key); + } + throw e; + })); + } + return evaluations.get(key)!; + }; + + function condition(name: string): Promise { + if (!conditions.has(name)) { + const definition = template.Conditions?.[name]; + conditions.set(name, definition === undefined + ? Promise.resolve(undefined) + : conditionValue(definition, `Conditions.${name}`)); + } + return conditions.get(name)!; + } + + async function conditionValue(expr: any, path: string): Promise { + if (expr !== null && typeof expr === 'object' && !Array.isArray(expr)) { + if (typeof expr.Condition === 'string' && Object.keys(expr).length === 1) { + return condition(expr.Condition); + } + const [fn] = Object.keys(expr); + const args = expr[fn]; + switch (fn) { + case 'Fn::Equals': { + const [a, b] = [await evaluate(args[0], `${path}.Fn::Equals.0`), await evaluate(args[1], `${path}.Fn::Equals.1`)]; + return containsExpression(a) || containsExpression(b) ? undefined : JSON.stringify(asCfnValue(a)) === JSON.stringify(asCfnValue(b)); + } + case 'Fn::Not': { + const v = await conditionValue(args[0], `${path}.Fn::Not.0`); + return v === undefined ? undefined : !v; + } + case 'Fn::And': + case 'Fn::Or': { + const values: Truth[] = []; + for (const [i, arg] of args.entries()) { + values.push(await conditionValue(arg, `${path}.${fn}.${i}`)); + } + const decisive = fn === 'Fn::And' ? false : true; + if (values.includes(decisive)) { + return decisive; + } + return values.includes(undefined) ? undefined : !decisive; + } + } + } + const value = await evaluate(expr, path); + return typeof value === 'boolean' ? value : value === 'true' ? true : value === 'false' ? false : undefined; + } + + /** `{ "Fn::X": { "Ref": "AWS::NoValue" } }`: CloudFormation rejects it, so keep the expression and report it */ + function noValueArgument(node: any, intrinsic: string, path: string) { + unresolved.push({ path, intrinsic, reason: 'its argument is AWS::NoValue' }); + return node; + } + + async function evaluate(node: any, path: string): Promise { + if (Array.isArray(node)) { + const out = []; + for (const [i, x] of node.entries()) { + const value = await evaluate(x, `${path}.${i}`); + if (value !== NO_VALUE) { + out.push(value); + } + } + return out; + } + if (node === null || typeof node !== 'object') { + if (typeof node === 'string' && DYNAMIC_REFERENCE.test(node)) { + unresolved.push({ path, intrinsic: 'DynamicReference', reason: 'contains a dynamic reference, which CloudFormation resolves at deployment' }); + } + return node; + } + const intrinsic = intrinsicName(node); + if (!intrinsic) { + const out: { [key: string]: any } = {}; + for (const [key, value] of Object.entries(node)) { + const evaluated = await evaluate(value, `${path}.${key}`); + if (evaluated !== NO_VALUE) { + out[key] = evaluated; + } + } + return out; + } + + if (intrinsic === 'Ref' && node.Ref === 'AWS::NoValue') { + return NO_VALUE; + } + if (intrinsic === 'Fn::If') { + const [conditionName, whenTrue, whenFalse] = node['Fn::If']; + const truth = await condition(conditionName); + if (truth === undefined) { + unresolved.push({ path, intrinsic, reason: `condition '${conditionName}' could not be evaluated` }); + return node; + } + return evaluate(truth ? whenTrue : whenFalse, `${path}.Fn::If.${truth ? 1 : 2}`); + } + + const masked = noEchoReferences(node, noEcho); + if (masked.length > 0) { + // Don't evaluate: the deployed value is masked, and the evaluator would fall back to the template default + const before = unresolved.length; + const args = intrinsic === 'Ref' ? node.Ref : await evaluate(node[intrinsic], `${path}.${intrinsic}`); + if (args === NO_VALUE) { + return noValueArgument(node, intrinsic, path); + } + if (intrinsic !== 'Ref' && unresolved.length === before && noEchoReferences({ [intrinsic]: args }, noEcho).length === 0) { + // the references were only in Fn::If branches that weren't taken + return evaluate({ [intrinsic]: args }, path); + } + if (unresolved.length === before) { + unresolved.push({ path, intrinsic, reason: masked.map((m) => `references parameter '${m}': ${unavailable[m]}`).join('; ') }); + } + return { [intrinsic]: args }; + } + try { + const value = await evaluateOnce(node); + if (value === undefined) { + unresolved.push({ path, intrinsic, reason: 'the expression evaluated to no value' }); + return node; + } + if (typeof value === 'string' && DYNAMIC_REFERENCE.test(value)) { + unresolved.push({ path, intrinsic, reason: 'evaluates to a dynamic reference, which CloudFormation resolves at deployment' }); + return node; + } + return value; + } catch (e) { + if (!isUnresolvable(e)) { + throw e; + } + const before = unresolved.length; + const args = await evaluate(node[intrinsic], `${path}.${intrinsic}`); + if (args === NO_VALUE) { + return noValueArgument(node, intrinsic, path); + } + const unsupported = e instanceof CfnUnsupportedFunctionException && e.functionName === intrinsic; + if (!unsupported && unresolved.length === before && !containsExpression(args)) { + // The arguments contained expressions only this function evaluates (Fn::If, AWS::NoValue): retry with their values + try { + return await evaluateOnce({ [intrinsic]: args }); + } catch (retryError) { + if (!isUnresolvable(retryError)) { + throw retryError; + } + unresolved.push({ path, intrinsic, reason: formatErrorMessage(retryError) }); + return { [intrinsic]: args }; + } + } + if (unsupported || unresolved.length === before) { + unresolved.push({ path, intrinsic, reason: formatErrorMessage(e) }); + } + return { [intrinsic]: args }; + } + } + + async function evaluateSection(section: { [logicalId: string]: any }, name: string) { + const out: { [logicalId: string]: any } = {}; + for (const [logicalId, element] of Object.entries(section)) { + if (typeof element?.Condition === 'string' && (await condition(element.Condition)) === false) { + continue; // not created by CloudFormation + } + out[logicalId] = await evaluate(element, `${name}.${logicalId}`); + } + return out; + } + + // Attribute lookups are the slow part: run the distinct ones up front, a few at a time, so the walk below gets them + // from `evaluations`. Only lookups the walk will make are run (not those in elements or Fn::If branches that + // CloudFormation didn't create). Evaluation failures are kept for the walk to report; any other error is thrown. + const lookups = new Map(); + async function collectLookups(value: any): Promise { + if (Array.isArray(value)) { + for (const x of value) { + await collectLookups(x); + } + } else if (value !== null && typeof value === 'object') { + const intrinsic = intrinsicName(value); + if (intrinsic === 'Fn::GetAtt' && !containsExpression(value['Fn::GetAtt'])) { + lookups.set(JSON.stringify(value), value); + } else if (intrinsic === 'Fn::Sub') { + // ${Resource.Attribute} placeholders are attribute lookups too + const [text, vars] = Array.isArray(value['Fn::Sub']) ? value['Fn::Sub'] : [value['Fn::Sub'], {}]; + for (const m of String(text).matchAll(/\$\{([^}!][^}]*)\}/g)) { + const [resource, ...attribute] = m[1].split('.'); + if (attribute.length > 0 && !(m[1] in (vars ?? {}))) { + const node = { 'Fn::GetAtt': [resource, attribute.join('.')] }; + lookups.set(JSON.stringify(node), node); + } + } + await collectLookups(vars); + } else if (intrinsic === 'Fn::If') { + const [conditionName, whenTrue, whenFalse] = value['Fn::If']; + const truth = await condition(conditionName); + if (truth !== undefined) { + await collectLookups(truth ? whenTrue : whenFalse); + } + } else { + for (const x of Object.values(value)) { + await collectLookups(x); + } + } + } + } + for (const section of ['Resources', 'Outputs']) { + for (const element of Object.values(template[section] ?? {})) { + if (!(typeof element?.Condition === 'string' && (await condition(element.Condition)) === false)) { + await collectLookups(element); + } + } + } + await parallelPromises(PREFETCH_CONCURRENCY, [...lookups.values()].map((node) => async () => { + try { + await evaluateOnce(node); + } catch (e) { + if (!isUnresolvable(e)) { + throw e; + } + } + })); + + const evaluated: Template = { ...template }; + for (const section of ['Resources', 'Outputs']) { + if (template[section]) { + evaluated[section] = await evaluateSection(template[section], section); + } + } + return { template: evaluated, unresolved }; +} + +function intrinsicName(node: { [key: string]: any }): string | undefined { + return parseIntrinsic(node)?.name; +} + +/** + * Lookup errors that mean the value doesn't exist or can't be read this way, rather than that the lookup itself failed + */ +const NOT_FOUND_ERRORS = new Set([ + 'ResourceNotFoundException', + 'TypeNotFoundException', + 'UnsupportedActionException', + 'InvalidRequestException', + 'ValidationException', + // the resource type's Cloud Control handler failed to read the resource + 'HandlerFailureException', + 'HandlerInternalFailureException', + 'GeneralServiceException', + // the credentials can't read this resource (e.g. least-privilege credentials, or a handler that needs more permissions); + // credentials that can't read the stack at all already fail on DescribeStacks + 'AccessDeniedException', +]); + +/** + * An evaluation failure that can be reported as unresolved: the evaluator could not evaluate the expression, + * not because a call to AWS failed (expired credentials, throttling, network) + */ +function isUnresolvable(e: unknown): boolean { + if (!(e instanceof CfnEvaluationException)) { + return false; + } + const cause = (e as { cause?: unknown }).cause; + if (cause === undefined || cause instanceof CfnEvaluationException) { + return cause === undefined || isUnresolvable(cause); + } + return NOT_FOUND_ERRORS.has((cause as { name?: string }).name ?? ''); +} + +/** Compare values the way CloudFormation does: scalars as strings (a deployed parameter `'10'` equals `10`) */ +function asCfnValue(value: any): any { + if (Array.isArray(value)) { + return value.map(asCfnValue); + } + return typeof value === 'number' || typeof value === 'boolean' ? String(value) : value; +} + +/** The NoEcho parameters an expression references, directly or through `Fn::Sub` placeholders */ +function noEchoReferences(node: any, noEcho: Set): string[] { + if (noEcho.size === 0) { + return []; + } + const found = new Set(); + const visit = (x: any) => { + if (Array.isArray(x)) { + x.forEach(visit); + } else if (x !== null && typeof x === 'object') { + if (typeof x.Ref === 'string' && noEcho.has(x.Ref)) { + found.add(x.Ref); + } + if ('Fn::Sub' in x) { + const [text, vars] = Array.isArray(x['Fn::Sub']) ? x['Fn::Sub'] : [x['Fn::Sub'], {}]; + for (const m of String(text).matchAll(/\$\{([^}!][^}]*)\}/g)) { + if (noEcho.has(m[1]) && !(m[1] in (vars ?? {}))) { + found.add(m[1]); + } + } + } + Object.values(x).forEach(visit); + } + }; + visit(node); + return [...found]; +} + +export function containsExpression(value: any): boolean { + if (Array.isArray(value)) { + return value.some(containsExpression); + } + if (value !== null && typeof value === 'object') { + return intrinsicName(value) !== undefined || Object.values(value).some(containsExpression); + } + return false; +} 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 e7b5761e0..8d4518414 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/index.ts @@ -3,4 +3,5 @@ export * from './template-body-parameter'; export * from './nested-stack-helpers'; export * from './stack-helpers'; export * from './logical-id-map'; +export * from './evaluate-template-best-effort'; export * from './shared-lookups'; diff --git a/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-template-best-effort.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-template-best-effort.test.ts new file mode 100644 index 000000000..bdf79defd --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/api/cloudformation/evaluate-template-best-effort.test.ts @@ -0,0 +1,433 @@ +import type { EvaluateCloudFormationTemplate } from '../../../lib/api/cloudformation'; +import { CfnEvaluationException, CfnUnsupportedFunctionException, evaluateTemplateBestEffort } from '../../../lib/api/cloudformation'; + +/** + * An evaluator that knows a fixed set of Refs and Fn::Join, and fails on everything else, + * mirroring how EvaluateCloudFormationTemplate evaluates nested arguments. + */ +function fakeEvaluator(refs: { [logicalId: string]: string }): EvaluateCloudFormationTemplate { + async function evaluateCfnExpression(expr: any): Promise { + if (Array.isArray(expr)) { + const out = []; + for (const x of expr) { + out.push(await evaluateCfnExpression(x)); + } + return out; + } + if (expr === null || typeof expr !== 'object') { + return expr; + } + if ('Ref' in expr) { + if (expr.Ref in refs) { + return refs[expr.Ref]; + } + throw new CfnEvaluationException(`Parameter or resource '${expr.Ref}' could not be found for evaluation`); + } + if ('Fn::Join' in expr) { + const [sep, parts] = expr['Fn::Join']; + return (await evaluateCfnExpression(parts)).join(sep); + } + throw new CfnUnsupportedFunctionException(Object.keys(expr)[0]); + } + return { evaluateCfnExpression } as unknown as EvaluateCloudFormationTemplate; +} + +/** An evaluator whose every evaluation fails with the given error */ +function failingEvaluator(error: Error): EvaluateCloudFormationTemplate & { calls: number } { + const evaluator = { + calls: 0, + async evaluateCfnExpression(): Promise { + evaluator.calls++; + throw error; + }, + }; + return evaluator as unknown as EvaluateCloudFormationTemplate & { calls: number }; +} + +describe('evaluateTemplateBestEffort', () => { + test('replaces every expression it can evaluate, in Resources and Outputs', async () => { + const result = await evaluateTemplateBestEffort(fakeEvaluator({ 'Bucket': 'my-bucket', 'AWS::Region': 'us-east-1' }), { + Resources: { + Param: { Type: 'AWS::SSM::Parameter', Properties: { Value: { Ref: 'Bucket' }, Tags: [{ Key: 'k', Value: 'literal' }] } }, + }, + Outputs: { + Joined: { Value: { 'Fn::Join': ['-', [{ Ref: 'Bucket' }, { Ref: 'AWS::Region' }]] } }, + }, + }); + + expect(result.unresolved).toEqual([]); + expect(result.template.Resources.Param.Properties).toEqual({ Value: 'my-bucket', Tags: [{ Key: 'k', Value: 'literal' }] }); + expect(result.template.Outputs.Joined.Value).toEqual('my-bucket-us-east-1'); + }); + + test('keeps expressions it cannot evaluate, without affecting the others', async () => { + const result = await evaluateTemplateBestEffort(fakeEvaluator({ Bucket: 'my-bucket' }), { + Resources: { + Param: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: { Ref: 'Bucket' }, + Description: { 'Fn::GetAZs': '' }, + }, + }, + }, + }); + + expect(result.template.Resources.Param.Properties).toEqual({ + Value: 'my-bucket', + Description: { 'Fn::GetAZs': '' }, + }); + expect(result.unresolved).toEqual([{ + path: 'Resources.Param.Properties.Description', + intrinsic: 'Fn::GetAZs', + reason: 'CloudFormation function Fn::GetAZs is not supported', + }]); + }); + + test('reports only the innermost failure, and evaluates the arguments around it', async () => { + const result = await evaluateTemplateBestEffort(fakeEvaluator({ Bucket: 'my-bucket' }), { + Outputs: { + Joined: { Value: { 'Fn::Join': ['-', [{ Ref: 'Bucket' }, { Ref: 'Missing' }]] } }, + }, + }); + + expect(result.template.Outputs.Joined.Value).toEqual({ 'Fn::Join': ['-', ['my-bucket', { Ref: 'Missing' }]] }); + expect(result.unresolved).toEqual([{ + path: 'Outputs.Joined.Value.Fn::Join.1.1', + intrinsic: 'Ref', + reason: "Parameter or resource 'Missing' could not be found for evaluation", + }]); + }); + + test('reports an unsupported function itself, rather than its arguments', async () => { + const result = await evaluateTemplateBestEffort(fakeEvaluator({}), { + Outputs: { Azs: { Value: { 'Fn::Select': [0, { 'Fn::GetAZs': 'us-east-1' }] } } }, + }); + + expect(result.unresolved.map((u) => [u.path, u.intrinsic])).toEqual([ + ['Outputs.Azs.Value.Fn::Select.1', 'Fn::GetAZs'], + ['Outputs.Azs.Value', 'Fn::Select'], + ]); + }); + + test('reports an expression that evaluates to no value, rather than dropping the property', async () => { + const evaluator = { evaluateCfnExpression: async () => undefined } as unknown as EvaluateCloudFormationTemplate; + + const result = await evaluateTemplateBestEffort(evaluator, { Outputs: { O: { Value: { 'Fn::Select': [5, ['a']] } } } }); + + expect(result.template.Outputs.O.Value).toEqual({ 'Fn::Select': [5, ['a']] }); + expect(result.unresolved).toEqual([{ path: 'Outputs.O.Value', intrinsic: 'Fn::Select', reason: 'the expression evaluated to no value' }]); + }); + + test('reports dynamic references, which CloudFormation resolves at deployment', async () => { + const result = await evaluateTemplateBestEffort(fakeEvaluator({ Secret: '{{resolve:secretsmanager:MySecret:SecretString:password}}' }), { + Resources: { + Db: { + Type: 'AWS::RDS::DBInstance', + Properties: { MasterUserPassword: { Ref: 'Secret' }, Parameter: '{{resolve:ssm:/my/param}}', Other: 'plain' }, + }, + }, + }); + + expect(result.template.Resources.Db.Properties).toEqual({ MasterUserPassword: { Ref: 'Secret' }, Parameter: '{{resolve:ssm:/my/param}}', Other: 'plain' }); + expect(result.unresolved.map((u) => [u.path, u.intrinsic])).toEqual([ + ['Resources.Db.Properties.MasterUserPassword', 'Ref'], + ['Resources.Db.Properties.Parameter', 'DynamicReference'], + ]); + }); + + test('leaves the other template sections untouched', async () => { + const template = { + Parameters: { Env: { Type: 'String', Default: { Ref: 'NotEvaluated' } } }, + Mappings: { M: { K: { V: { Ref: 'NotEvaluated' } } } }, + Resources: {}, + }; + + const result = await evaluateTemplateBestEffort(fakeEvaluator({}), template); + + expect(result.template.Parameters).toBe(template.Parameters); + expect(result.template.Mappings).toBe(template.Mappings); + expect(result.unresolved).toEqual([]); + }); + + describe('conditions', () => { + const conditions = { + IsProd: { 'Fn::Equals': [{ Ref: 'Env' }, 'prod'] }, + IsDev: { 'Fn::Not': [{ Condition: 'IsProd' }] }, + IsProdInUsEast1: { 'Fn::And': [{ Condition: 'IsProd' }, { 'Fn::Equals': [{ Ref: 'AWS::Region' }, 'us-east-1'] }] }, + IsProdOrDev: { 'Fn::Or': [{ Condition: 'IsProd' }, { Condition: 'IsDev' }] }, + Unknowable: { 'Fn::Equals': [{ Ref: 'Missing' }, 'x'] }, + }; + const evaluator = fakeEvaluator({ 'Env': 'prod', 'AWS::Region': 'us-east-1', 'Bucket': 'my-bucket' }); + + test('picks the Fn::If branch of the evaluated condition', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Conditions: conditions, + Outputs: { + A: { Value: { 'Fn::If': ['IsProd', 'prod-value', 'dev-value'] } }, + B: { Value: { 'Fn::If': ['IsDev', 'dev-value', { Ref: 'Bucket' }] } }, + C: { Value: { 'Fn::If': ['IsProdInUsEast1', 'yes', 'no'] } }, + D: { Value: { 'Fn::If': ['IsProdOrDev', 'yes', 'no'] } }, + }, + }); + + expect(result.unresolved).toEqual([]); + expect(Object.fromEntries(Object.entries(result.template.Outputs).map(([k, v]: [string, any]) => [k, v.Value]))).toEqual({ + A: 'prod-value', B: 'my-bucket', C: 'yes', D: 'yes', + }); + }); + + test('removes properties and list elements that are AWS::NoValue', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Conditions: conditions, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { + BucketName: { 'Fn::If': ['IsDev', 'dev-bucket', { Ref: 'AWS::NoValue' }] }, + Tags: [{ Key: 'a', Value: '1' }, { 'Fn::If': ['IsDev', { Key: 'b', Value: '2' }, { Ref: 'AWS::NoValue' }] }], + }, + }, + }, + }); + + expect(result.unresolved).toEqual([]); + expect(result.template.Resources.Bucket.Properties).toEqual({ Tags: [{ Key: 'a', Value: '1' }] }); + }); + + test('reports a function whose whole argument is AWS::NoValue, instead of leaking it', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Conditions: conditions, + Outputs: { Encoded: { Value: { 'Fn::Base64': { 'Fn::If': ['IsDev', 'dev', { Ref: 'AWS::NoValue' }] } } } }, + }); + + expect(result.template.Outputs.Encoded.Value).toEqual({ 'Fn::Base64': { 'Fn::If': ['IsDev', 'dev', { Ref: 'AWS::NoValue' }] } }); + expect(result.unresolved).toEqual([{ path: 'Outputs.Encoded.Value', intrinsic: 'Fn::Base64', reason: 'its argument is AWS::NoValue' }]); + }); + + test('evaluates functions whose arguments contain Fn::If', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Conditions: conditions, + Outputs: { Joined: { Value: { 'Fn::Join': ['-', [{ Ref: 'Bucket' }, { 'Fn::If': ['IsProd', 'prod', 'dev'] }]] } } }, + }); + + expect(result.unresolved).toEqual([]); + expect(result.template.Outputs.Joined.Value).toEqual('my-bucket-prod'); + }); + + test('drops resources and outputs whose condition is false', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Conditions: conditions, + Resources: { + ProdOnly: { Type: 'AWS::SNS::Topic', Condition: 'IsProd' }, + DevOnly: { Type: 'AWS::SNS::Topic', Condition: 'IsDev' }, + }, + Outputs: { DevOutput: { Condition: 'IsDev', Value: 'x' } }, + }); + + expect(Object.keys(result.template.Resources)).toEqual(['ProdOnly']); + expect(result.template.Outputs).toEqual({}); + }); + + test('keeps Fn::If and reports it when the condition cannot be evaluated', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Conditions: conditions, + Outputs: { Value: { Value: { 'Fn::If': ['Unknowable', 'a', 'b'] } } }, + }); + + expect(result.template.Outputs.Value.Value).toEqual({ 'Fn::If': ['Unknowable', 'a', 'b'] }); + expect(result.unresolved).toEqual([ + { path: 'Conditions.Unknowable.Fn::Equals.0', intrinsic: 'Ref', reason: "Parameter or resource 'Missing' could not be found for evaluation" }, + { path: 'Outputs.Value.Value', intrinsic: 'Fn::If', reason: "condition 'Unknowable' could not be evaluated" }, + ]); + }); + }); + + describe('unavailable parameters', () => { + const parameters = { Secret: { Type: 'String', NoEcho: true }, Env: { Type: 'String' } }; + // CloudFormation returns '****' for NoEcho values: the evaluator would happily use it + const evaluator = fakeEvaluator({ Secret: '****', Env: 'prod', Bucket: 'my-bucket' }); + const options = { unavailableParameters: { Secret: 'NoEcho parameter' } }; + + test('reports expressions that reference them instead of evaluating them', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Parameters: parameters, + Outputs: { + Direct: { Value: { Ref: 'Secret' } }, + Joined: { Value: { 'Fn::Join': ['-', [{ Ref: 'Bucket' }, { Ref: 'Secret' }]] } }, + Sub: { Value: { 'Fn::Sub': 'password=${Secret}' } }, + }, + }, options); + + expect(result.template.Outputs.Direct.Value).toEqual({ Ref: 'Secret' }); + expect(result.template.Outputs.Joined.Value).toEqual({ 'Fn::Join': ['-', ['my-bucket', { Ref: 'Secret' }]] }); + expect(result.template.Outputs.Sub.Value).toEqual({ 'Fn::Sub': 'password=${Secret}' }); + expect(result.unresolved.map((u) => [u.path, u.intrinsic])).toEqual([ + ['Outputs.Direct.Value', 'Ref'], + ['Outputs.Joined.Value.Fn::Join.1.1', 'Ref'], + ['Outputs.Sub.Value', 'Fn::Sub'], + ]); + expect(result.unresolved[0].reason).toEqual("references parameter 'Secret': NoEcho parameter"); + }); + + test('a condition that uses one cannot be evaluated', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Parameters: parameters, + Conditions: { HasSecret: { 'Fn::Not': [{ 'Fn::Equals': [{ Ref: 'Secret' }, ''] }] } }, + Outputs: { Value: { Value: { 'Fn::If': ['HasSecret', 'yes', 'no'] } } }, + }, options); + + expect(result.template.Outputs.Value.Value).toEqual({ 'Fn::If': ['HasSecret', 'yes', 'no'] }); + expect(result.unresolved.map((u) => u.intrinsic)).toEqual(['Ref', 'Fn::If']); + }); + + test('only references in the Fn::If branch that is taken count', async () => { + const result = await evaluateTemplateBestEffort(evaluator, { + Parameters: parameters, + Conditions: { IsProd: { 'Fn::Equals': [{ Ref: 'Env' }, 'prod'] } }, + Outputs: { + Value: { Value: { 'Fn::If': ['IsProd', 'prod', { Ref: 'Secret' }] } }, + Joined: { Value: { 'Fn::Join': ['-', [{ Ref: 'Bucket' }, { 'Fn::If': ['IsProd', 'prod', { Ref: 'Secret' }] }]] } }, + }, + }, options); + + expect(result.unresolved).toEqual([]); + expect(result.template.Outputs.Value.Value).toEqual('prod'); + expect(result.template.Outputs.Joined.Value).toEqual('my-bucket-prod'); + }); + }); + + test('compares values in Fn::Equals the way CloudFormation does', async () => { + const result = await evaluateTemplateBestEffort(fakeEvaluator({ Size: '10', Flag: 'true' }), { + Conditions: { + IsTen: { 'Fn::Equals': [{ Ref: 'Size' }, 10] }, + IsFlag: { 'Fn::Equals': [true, { Ref: 'Flag' }] }, + }, + Resources: { + WhenTen: { Type: 'AWS::SNS::Topic', Condition: 'IsTen' }, + WhenFlag: { Type: 'AWS::SNS::Topic', Condition: 'IsFlag' }, + }, + }); + + expect(Object.keys(result.template.Resources)).toEqual(['WhenTen', 'WhenFlag']); + }); + + describe('errors', () => { + const template = { Outputs: { A: { Value: { Ref: 'X' } }, B: { Value: { Ref: 'X' } } } }; + + test('throws errors that are not evaluation failures', async () => { + await expect(evaluateTemplateBestEffort(failingEvaluator(new TypeError('boom')), template)).rejects.toThrow('boom'); + }); + + test('throws lookup failures caused by AWS errors such as throttling or expired credentials', async () => { + const cause = Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException' }); + const evaluator = failingEvaluator(new CfnEvaluationException('Could not find attribute', { cause })); + + await expect(evaluateTemplateBestEffort(evaluator, template)).rejects.toThrow('Could not find attribute'); + }); + + test('looks up attributes before the walk, a few at a time, and throws lookup failures from the walk', async () => { + let running = 0; + let maxRunning = 0; + const cause = Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException' }); + const evaluator = { + calls: [] as string[], + async evaluateCfnExpression(expr: any): Promise { + evaluator.calls.push(JSON.stringify(expr)); + running++; + maxRunning = Math.max(maxRunning, running); + await new Promise((resolve) => setTimeout(resolve, 5)); + running--; + if (expr['Fn::GetAtt'][0] === 'Throttled') { + throw new CfnEvaluationException('Could not find attribute', { cause }); + } + return `${expr['Fn::GetAtt'][0]}.${expr['Fn::GetAtt'][1]}`; + }, + }; + const outputs = Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`O${i}`, { Value: { 'Fn::GetAtt': [`R${i}`, 'Arn'] } }])); + + const result = await evaluateTemplateBestEffort(evaluator as unknown as EvaluateCloudFormationTemplate, { Outputs: outputs }); + expect(maxRunning).toEqual(8); + expect(evaluator.calls).toHaveLength(12); + expect(result.template.Outputs.O11.Value).toEqual('R11.Arn'); + + await expect(evaluateTemplateBestEffort(evaluator as unknown as EvaluateCloudFormationTemplate, { + Outputs: { ...outputs, Bad: { Value: { 'Fn::GetAtt': ['Throttled', 'Arn'] } } }, + })).rejects.toThrow('Could not find attribute'); + }); + + test('only looks up attributes in elements and Fn::If branches that CloudFormation creates', async () => { + const evaluator = { + calls: [] as string[], + async evaluateCfnExpression(expr: any): Promise { + if ('Fn::GetAtt' in expr) { + evaluator.calls.push(expr['Fn::GetAtt'][0]); + return 'value'; + } + if ('Fn::Equals' in expr) { + return false; + } + return expr; + }, + }; + + await evaluateTemplateBestEffort(evaluator as unknown as EvaluateCloudFormationTemplate, { + Conditions: { Never: { 'Fn::Equals': ['a', 'b'] } }, + Resources: { NotCreated: { Type: 'AWS::SNS::Topic', Condition: 'Never', Properties: { A: { 'Fn::GetAtt': ['InNotCreated', 'Arn'] } } } }, + Outputs: { O: { Value: { 'Fn::If': ['Never', { 'Fn::GetAtt': ['InUntakenBranch', 'Arn'] }, { 'Fn::GetAtt': ['InTakenBranch', 'Arn'] }] } } }, + }); + + expect(evaluator.calls).toEqual(['InTakenBranch']); + }); + + test('also looks up attributes referenced in Fn::Sub placeholders up front', async () => { + const evaluator = { + calls: [] as string[], + async evaluateCfnExpression(expr: any): Promise { + evaluator.calls.push(JSON.stringify(expr)); + return 'x'; + }, + }; + + await evaluateTemplateBestEffort(evaluator as unknown as EvaluateCloudFormationTemplate, { + Outputs: { O: { Value: { 'Fn::Sub': ['${Topic.TopicName}-${AWS::Region}-${!Literal}-${Var}', { Var: { 'Fn::GetAtt': ['Queue', 'Arn'] } }] } } }, + }); + + // the two lookups run before the walk evaluates the Fn::Sub itself + expect(evaluator.calls.slice(0, 2).sort()).toEqual([ + JSON.stringify({ 'Fn::GetAtt': ['Queue', 'Arn'] }), + JSON.stringify({ 'Fn::GetAtt': ['Topic', 'TopicName'] }), + ]); + }); + + test('reports lookups the credentials are not allowed to make', async () => { + const cause = Object.assign(new Error('not authorized'), { name: 'AccessDeniedException' }); + + const result = await evaluateTemplateBestEffort(failingEvaluator(new CfnEvaluationException('Could not find attribute', { cause })), template); + + expect(result.unresolved.map((u) => u.path)).toEqual(['Outputs.A.Value', 'Outputs.B.Value']); + }); + + test('treats a failed lookup inside another evaluation failure like the inner failure', async () => { + const notFound = Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' }); + const handlerFailed = Object.assign(new Error('handler failed'), { name: 'HandlerFailureException' }); + const throttled = Object.assign(new Error('Rate exceeded'), { name: 'ThrottlingException' }); + const wrapped = (cause: Error) => new CfnEvaluationException('outer', { cause: new CfnEvaluationException('inner', { cause }) }); + + expect((await evaluateTemplateBestEffort(failingEvaluator(wrapped(notFound)), template)).unresolved).toHaveLength(2); + expect((await evaluateTemplateBestEffort(failingEvaluator(new CfnEvaluationException('x', { cause: handlerFailed })), template)).unresolved) + .toHaveLength(2); + await expect(evaluateTemplateBestEffort(failingEvaluator(wrapped(throttled)), template)).rejects.toThrow('outer'); + }); + + test('reports lookups of resources that do not exist, and evaluates the same expression only once', async () => { + const cause = Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' }); + const evaluator = failingEvaluator(new CfnEvaluationException('Could not find attribute', { cause })); + + const result = await evaluateTemplateBestEffort(evaluator, template); + + expect(result.unresolved.map((u) => u.path)).toEqual(['Outputs.A.Value', 'Outputs.B.Value']); + expect(evaluator.calls).toEqual(1); + }); + }); +});