Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -136,6 +146,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 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 {
Expand All @@ -157,6 +185,8 @@ export class EvaluateCloudFormationTemplate {
private cachedUrlSuffix: string | undefined;
private _cloudControl: ICloudControlClient | undefined;
private readonly createOnlyPropertiesCache = new Map<string, Set<string>>();
private readonly unavailableParameters: Set<string>;
private readonly lookups: SharedLookups | undefined;

constructor(props: EvaluateCloudFormationTemplateProps) {
this.stackArtifact = props.stackArtifact;
Expand Down Expand Up @@ -190,6 +220,14 @@ 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;
}

/** Whether a looked-up value counts as found: like CloudFormation, an empty string (or `0`, `false`) is a value */
private isValue<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}

private get cloudControl(): ICloudControlClient {
Expand Down Expand Up @@ -217,6 +255,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,
});
}

Expand Down Expand Up @@ -275,7 +315,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];
Expand All @@ -298,12 +338,15 @@ export class EvaluateCloudFormationTemplate {
async 'Fn::Select'(index: number, args: any[]): Promise<string> {
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}`);
}
return evaluatedArgs[index];
}

async Ref(logicalId: string): Promise<string> {
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`);
Expand All @@ -313,7 +356,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 (self.isValue(attrValue)) {
return attrValue;
} else {
throw new CfnEvaluationException(
Expand All @@ -326,6 +369,9 @@ export class EvaluateCloudFormationTemplate {
const placeholders = explicitPlaceholders ? await self.evaluateCfnExpression(explicitPlaceholders) : {};

return asyncGlobalReplace(template, /\${([^}]*)}/g, (key) => {
if (key.startsWith('!')) {
return `\${${key.slice(1)}}`; // ${!Literal} is the literal text ${Literal}
}
if (key in placeholders) {
return placeholders[key];
} else {
Expand Down Expand Up @@ -358,7 +404,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 {
Expand Down Expand Up @@ -484,17 +530,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') {
Expand All @@ -505,15 +540,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;
}

Expand All @@ -525,7 +564,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();
Expand All @@ -534,6 +573,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) {
Expand Down Expand Up @@ -603,18 +647,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(
Expand Down Expand Up @@ -796,11 +845,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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -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<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]), 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<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;
}
}
Loading
Loading