Repository navigation
fix(toolkit-lib): stricter, opt-in evaluation modes for EvaluateCloudFormationTemplate #2074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d29a9f2
5713e28
deee4cd
9d9e9f1
f6177fe
64ec3ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 { getResourceProperties, 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<T>(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<string, ListStackResources>; | ||
|
|
||
| /** | ||
| * 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 these 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,12 @@ export class EvaluateCloudFormationTemplate { | |
|
|
||
| private cachedUrlSuffix: string | undefined; | ||
| private _cloudControl: ICloudControlClient | undefined; | ||
| private _cloudFormation: ICloudFormationClient | undefined; | ||
| private readonly createOnlyPropertiesCache = new Map<string, Set<string>>(); | ||
| private readonly unavailableParameters: Set<string>; | ||
| private readonly lookups: SharedLookups | undefined; | ||
| /** The environment of this evaluator's lookups, so the same identifier in two environments isn't shared */ | ||
| private readonly lookupScope: string; | ||
|
|
||
| constructor(props: EvaluateCloudFormationTemplateProps) { | ||
| this.stackArtifact = props.stackArtifact; | ||
|
|
@@ -190,6 +228,17 @@ 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; | ||
| this.lookupScope = `${this.account}/${this.region}`; | ||
| } | ||
|
|
||
| private get cloudFormation(): ICloudFormationClient { | ||
| if (!this._cloudFormation) { | ||
| this._cloudFormation = this.sdk.cloudFormation(); | ||
| } | ||
| return this._cloudFormation; | ||
| } | ||
|
|
||
| private get cloudControl(): ICloudControlClient { | ||
|
|
@@ -217,6 +266,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 +326,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 +346,24 @@ export class EvaluateCloudFormationTemplate { | |
| return evaluatedArgs.split(separator); | ||
| } | ||
|
|
||
| async 'Fn::Select'(index: number, args: any[]): Promise<string> { | ||
| async 'Fn::Select'(index: any, args: any[]): Promise<string> { | ||
| // 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' && /^\d+$/.test(evaluatedIndex) ? Number(evaluatedIndex) : evaluatedIndex; | ||
| if (!Number.isInteger(i)) { | ||
| throw new CfnEvaluationException(`Fn::Select: expected an integer index, got ${JSON.stringify(evaluatedIndex)}`); | ||
| } | ||
|
Comment on lines
+352
to
+355
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is going to come up a lot. Put it in a helper function. |
||
| 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<string> { | ||
| 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 +373,7 @@ export class EvaluateCloudFormationTemplate { | |
| async 'Fn::GetAtt'(logicalId: string, attributeName: string): Promise<string> { | ||
| // 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,7 +386,10 @@ export class EvaluateCloudFormationTemplate { | |
| const placeholders = explicitPlaceholders ? await self.evaluateCfnExpression(explicitPlaceholders) : {}; | ||
|
|
||
| return asyncGlobalReplace(template, /\${([^}]*)}/g, (key) => { | ||
| if (key in placeholders) { | ||
| if (key.startsWith('!')) { | ||
| return `\${${key.slice(1)}}`; // ${!Literal} is the literal text ${Literal} | ||
| } | ||
| if (Object.hasOwn(placeholders, key)) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you are really concerned about prototype inheritance, use But are we realistically concerned about this to begin with? |
||
| return placeholders[key]; | ||
| } else { | ||
| const splitKey = key.split('.'); | ||
|
|
@@ -358,7 +421,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 { | ||
|
|
@@ -399,7 +462,7 @@ export class EvaluateCloudFormationTemplate { | |
|
|
||
| for (const propName of primaryProps) { | ||
| const templateVal = this.getResourceProperty(logicalId, propName); | ||
| if (templateVal) { | ||
| if (isValue(templateVal)) { | ||
| parts.push(await this.evaluateCfnExpression(templateVal)); | ||
| } else { | ||
| // Not in template — assume this is the Ref return value. See doc comment for caveats. | ||
|
|
@@ -432,7 +495,7 @@ export class EvaluateCloudFormationTemplate { | |
|
|
||
| private async fetchCreateOnlyProperties(resourceType: string): Promise<Set<string>> { | ||
| try { | ||
| const response = await this.sdk.cloudFormation().describeType({ | ||
| const response = await this.cloudFormation.describeType({ | ||
| Type: 'RESOURCE', | ||
| TypeName: resourceType, | ||
| }); | ||
|
|
@@ -484,17 +547,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<string | undefined> { | ||
| // first, check to see if the Ref is a Parameter who's value we have | ||
| if (logicalId === 'AWS::URLSuffix') { | ||
|
|
@@ -505,15 +557,18 @@ 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`); | ||
| } | ||
|
|
||
|
Comment on lines
+560
to
+563
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why isn't this part of "context" is also very generically named. We should be using discriminated unions wayyyyyyyy more, they are extremely useful in modeling data. type ContextValue =
| { type: 'resolved', value: any }
| { type: 'unavailable' };
class EvaluateCloudFormationTemplate {
// Make it a Map so we don't have to be so scared of prototype inheritance
private readonly context = new Map<string, ContextValue>();
private async findRefTarget(logicalId: string): Promise<string | undefined> {
// ...
const v = this.context.get(logicalId);
if (v) {
switch (v.type) {
case 'resolved': return v.value;
case 'unavailable': throw new Error('oh no');
}
}
}
} |
||
| // Try finding the ref in the passed in parameters | ||
| const parameterTarget = this.context[logicalId]; | ||
| if (parameterTarget) { | ||
| return parameterTarget; | ||
| if (Object.hasOwn(this.context, logicalId) && isValue(this.context[logicalId])) { | ||
| return this.context[logicalId]; | ||
| } | ||
|
|
||
| // 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 +580,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 +590,16 @@ 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.lookupScope, foundResource.PhysicalResourceId); | ||
| const outputName = attribute.slice('Outputs.'.length); | ||
| return Object.hasOwn(outputs, outputName) ? outputs[outputName] : undefined; | ||
| } | ||
| const dependantStack = this.findNestedStack(logicalId, this.nestedStacks); | ||
| if (!dependantStack || !dependantStack.physicalName) { | ||
| // this is a newly created nested stack and cannot be hotswapped | ||
|
|
@@ -603,18 +668,20 @@ 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 ?? '{}'); | ||
| if (attribute in props) { | ||
| const props = this.lookups | ||
| ? await this.lookups.resourceProperties(this.cloudControl, this.lookupScope, resource.ResourceType!, identifier) | ||
| : await getResourceProperties(this.cloudControl, resource.ResourceType!, identifier); | ||
| if (Object.hasOwn(props, attribute)) { | ||
| 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}'`); | ||
| throw new CfnEvaluationException(`Could not find '${attribute}' attribute of the '${resource.ResourceType}' resource because ` + | ||
| `an error occurred 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::<Name>": ... }` | ||
| */ | ||
| 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<string>): Promise<string> { | ||
| if (!regex.global) { | ||
| throw new ToolkitError('RegexMissingGlobalFlag', 'Regex must be created with /g flag'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| 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` of these lookups (Cloud Control `GetResource` | ||
| * and `DescribeStacks` of nested stacks) run at the same time across all evaluators, however many stacks and | ||
| * nested stacks are evaluated in parallel. The evaluators' other calls (such as `ListStackResources`, | ||
| * `ListExports` and `DescribeType`) are not limited. A failed lookup is not remembered. | ||
| */ | ||
| export class SharedLookups { | ||
| private readonly limit: ReturnType<typeof pLimit>; | ||
| private readonly resources = new Map<string, Promise<{ [property: string]: any }>>(); | ||
| private readonly stackOutputs = new Map<string, Promise<{ [output: string]: string }>>(); | ||
|
|
||
| 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]), () => | ||
| getResourceProperties(cloudControl, typeName, identifier)); | ||
| } | ||
|
|
||
| /** | ||
| * 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<T>(cache: Map<string, Promise<T>>, key: string, lookup: () => Promise<T>): Promise<T> { | ||
| let result = cache.get(key); | ||
| if (!result) { | ||
| result = this.limit(lookup).catch((e) => { | ||
| cache.delete(key); | ||
| throw e; | ||
| }); | ||
| cache.set(key, result); | ||
| } | ||
| return result; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * The properties of a resource, read through Cloud Control | ||
| */ | ||
| export async function getResourceProperties( | ||
| cloudControl: ICloudControlClient, | ||
| typeName: string, | ||
| identifier: string, | ||
| ): Promise<{ [property: string]: any }> { | ||
| const response = await cloudControl.getResource({ TypeName: typeName, Identifier: identifier }); | ||
| return JSON.parse(response.ResourceDescription?.Properties ?? '{}'); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.