packages/@aws-cdk/toolkit-lib/lib/api/cloudformation/evaluate-cloudformation-template.ts (465 lines of code) (raw):
import type { CloudFormationStackArtifact } from '@aws-cdk/cx-api';
import type { Export, ListExportsCommandOutput, StackResourceSummary } from '@aws-sdk/client-cloudformation';
import type { SDK } from '../aws-auth/private';
import { ToolkitError } from '../toolkit-error';
import type { NestedStackTemplates } from './nested-stack-helpers';
import type { Template } from './stack-helpers';
import type { ResourceMetadata } from '../resource-metadata';
import { resourceMetadata } from '../resource-metadata';
export interface ListStackResources {
listStackResources(): Promise<StackResourceSummary[]>;
}
export class LazyListStackResources implements ListStackResources {
private stackResources: Promise<StackResourceSummary[]> | undefined;
constructor(
private readonly sdk: SDK,
private readonly stackName: string,
) {
}
public async listStackResources(): Promise<StackResourceSummary[]> {
if (this.stackResources === undefined) {
this.stackResources = this.sdk.cloudFormation().listStackResources({
StackName: this.stackName,
});
}
return this.stackResources;
}
}
export interface LookupExport {
lookupExport(name: string): Promise<Export | undefined>;
}
export class LookupExportError extends Error {
}
export class LazyLookupExport implements LookupExport {
private cachedExports: { [name: string]: Export } = {};
constructor(private readonly sdk: SDK) {
}
async lookupExport(name: string): Promise<Export | undefined> {
if (this.cachedExports[name]) {
return this.cachedExports[name];
}
for await (const cfnExport of this.listExports()) {
if (!cfnExport.Name) {
continue; // ignore any result that omits a name
}
this.cachedExports[cfnExport.Name] = cfnExport;
if (cfnExport.Name === name) {
return cfnExport;
}
}
return undefined; // export not found
}
// TODO: Paginate
private async *listExports() {
let nextToken: string | undefined = undefined;
while (true) {
const response: ListExportsCommandOutput = await this.sdk.cloudFormation().listExports({ NextToken: nextToken });
for (const cfnExport of response.Exports ?? []) {
yield cfnExport;
}
if (!response.NextToken) {
return;
}
nextToken = response.NextToken;
}
}
}
export class CfnEvaluationException extends Error {
}
export interface ResourceDefinition {
readonly LogicalId: string;
readonly Type: string;
readonly Properties: { [p: string]: any };
}
export interface EvaluateCloudFormationTemplateProps {
readonly stackArtifact: CloudFormationStackArtifact;
readonly stackName?: string;
readonly template?: Template;
readonly parameters: { [parameterName: string]: string };
readonly account: string;
readonly region: string;
readonly partition: string;
readonly sdk: SDK;
readonly nestedStacks?: {
[nestedStackLogicalId: string]: NestedStackTemplates;
};
}
export class EvaluateCloudFormationTemplate {
public readonly stackArtifact: CloudFormationStackArtifact;
private readonly stackName: string;
private readonly template: Template;
private readonly context: { [k: string]: any };
private readonly account: string;
private readonly region: string;
private readonly partition: string;
private readonly sdk: SDK;
private readonly nestedStacks: {
[nestedStackLogicalId: string]: NestedStackTemplates;
};
private readonly stackResources: ListStackResources;
private readonly lookupExport: LookupExport;
private cachedUrlSuffix: string | undefined;
constructor(props: EvaluateCloudFormationTemplateProps) {
this.stackArtifact = props.stackArtifact;
this.stackName = props.stackName ?? props.stackArtifact.stackName;
this.template = props.template ?? props.stackArtifact.template;
this.context = {
'AWS::AccountId': props.account,
'AWS::Region': props.region,
'AWS::Partition': props.partition,
...props.parameters,
};
this.account = props.account;
this.region = props.region;
this.partition = props.partition;
this.sdk = props.sdk;
// We need names of nested stack so we can evaluate cross stack references
this.nestedStacks = props.nestedStacks ?? {};
// The current resources of the Stack.
// We need them to figure out the physical name of a resource in case it wasn't specified by the user.
// We fetch it lazily, to save a service call, in case all hotswapped resources have their physical names set.
this.stackResources = new LazyListStackResources(this.sdk, this.stackName);
// CloudFormation Exports lookup to be able to resolve Fn::ImportValue intrinsics in template
this.lookupExport = new LazyLookupExport(this.sdk);
}
// clones current EvaluateCloudFormationTemplate object, but updates the stack name
public async createNestedEvaluateCloudFormationTemplate(
stackName: string,
nestedTemplate: Template,
nestedStackParameters: { [parameterName: string]: any },
) {
const evaluatedParams = await this.evaluateCfnExpression(nestedStackParameters);
return new EvaluateCloudFormationTemplate({
stackArtifact: this.stackArtifact,
stackName,
template: nestedTemplate,
parameters: evaluatedParams,
account: this.account,
region: this.region,
partition: this.partition,
sdk: this.sdk,
nestedStacks: this.nestedStacks,
});
}
public async establishResourcePhysicalName(
logicalId: string,
physicalNameInCfnTemplate: any,
): Promise<string | undefined> {
if (physicalNameInCfnTemplate != null) {
try {
return await this.evaluateCfnExpression(physicalNameInCfnTemplate);
} catch (e) {
// If we can't evaluate the resource's name CloudFormation expression,
// just look it up in the currently deployed Stack
if (!(e instanceof CfnEvaluationException)) {
throw e;
}
}
}
return this.findPhysicalNameFor(logicalId);
}
public async findPhysicalNameFor(logicalId: string): Promise<string | undefined> {
const stackResources = await this.stackResources.listStackResources();
return stackResources.find((sr) => sr.LogicalResourceId === logicalId)?.PhysicalResourceId;
}
public async findLogicalIdForPhysicalName(physicalName: string): Promise<string | undefined> {
const stackResources = await this.stackResources.listStackResources();
return stackResources.find((sr) => sr.PhysicalResourceId === physicalName)?.LogicalResourceId;
}
public findReferencesTo(logicalId: string): Array<ResourceDefinition> {
const ret = new Array<ResourceDefinition>();
for (const [resourceLogicalId, resourceDef] of Object.entries(this.template?.Resources ?? {})) {
if (logicalId !== resourceLogicalId && this.references(logicalId, resourceDef)) {
ret.push({
...(resourceDef as any),
LogicalId: resourceLogicalId,
});
}
}
return ret;
}
public async evaluateCfnExpression(cfnExpression: any): Promise<any> {
const self = this;
/**
* Evaluates CloudFormation intrinsic functions
*
* Note that supported intrinsic functions are documented in README.md -- please update
* list of supported functions when adding new evaluations
*
* See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html
*/
class CfnIntrinsics {
public evaluateIntrinsic(intrinsic: Intrinsic): any {
const intrinsicFunc = (this as any)[intrinsic.name];
if (!intrinsicFunc) {
throw new CfnEvaluationException(`CloudFormation function ${intrinsic.name} is not supported`);
}
const argsAsArray = Array.isArray(intrinsic.args) ? intrinsic.args : [intrinsic.args];
return intrinsicFunc.apply(this, argsAsArray);
}
async 'Fn::Join'(separator: string, args: any[]): Promise<string> {
const evaluatedArgs = await self.evaluateCfnExpression(args);
return evaluatedArgs.join(separator);
}
async 'Fn::Split'(separator: string, args: any): Promise<string> {
const evaluatedArgs = await self.evaluateCfnExpression(args);
return evaluatedArgs.split(separator);
}
async 'Fn::Select'(index: number, args: any[]): Promise<string> {
const evaluatedArgs = await self.evaluateCfnExpression(args);
return evaluatedArgs[index];
}
async Ref(logicalId: string): Promise<string> {
const refTarget = await self.findRefTarget(logicalId);
if (refTarget) {
return refTarget;
} else {
throw new CfnEvaluationException(`Parameter or resource '${logicalId}' could not be found for evaluation`);
}
}
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) {
return attrValue;
} else {
throw new CfnEvaluationException(
`Attribute '${attributeName}' of resource '${logicalId}' could not be found for evaluation`,
);
}
}
async 'Fn::Sub'(template: string, explicitPlaceholders?: { [variable: string]: string }): Promise<string> {
const placeholders = explicitPlaceholders ? await self.evaluateCfnExpression(explicitPlaceholders) : {};
return asyncGlobalReplace(template, /\${([^}]*)}/g, (key) => {
if (key in placeholders) {
return placeholders[key];
} else {
const splitKey = key.split('.');
return splitKey.length === 1 ? this.Ref(key) : this['Fn::GetAtt'](splitKey[0], splitKey.slice(1).join('.'));
}
});
}
async 'Fn::ImportValue'(name: string): Promise<string> {
const exported = await self.lookupExport.lookupExport(name);
if (!exported) {
throw new CfnEvaluationException(`Export '${name}' could not be found for evaluation`);
}
if (!exported.Value) {
throw new CfnEvaluationException(`Export '${name}' exists without a value`);
}
return exported.Value;
}
}
if (cfnExpression == null) {
return cfnExpression;
}
if (Array.isArray(cfnExpression)) {
// Small arrays in practice
// eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism
return Promise.all(cfnExpression.map((expr) => this.evaluateCfnExpression(expr)));
}
if (typeof cfnExpression === 'object') {
const intrinsic = this.parseIntrinsic(cfnExpression);
if (intrinsic) {
return new CfnIntrinsics().evaluateIntrinsic(intrinsic);
} else {
const ret: { [key: string]: any } = {};
for (const [key, val] of Object.entries(cfnExpression)) {
ret[key] = await this.evaluateCfnExpression(val);
}
return ret;
}
}
return cfnExpression;
}
public getResourceProperty(logicalId: string, propertyName: string): any {
return this.template.Resources?.[logicalId]?.Properties?.[propertyName];
}
public metadataFor(logicalId: string): ResourceMetadata | undefined {
return resourceMetadata(this.stackArtifact, logicalId);
}
private references(logicalId: string, templateElement: any): boolean {
if (typeof templateElement === 'string') {
return logicalId === templateElement;
}
if (templateElement == null) {
return false;
}
if (Array.isArray(templateElement)) {
return templateElement.some((el) => this.references(logicalId, el));
}
if (typeof templateElement === 'object') {
return Object.values(templateElement).some((el) => this.references(logicalId, el));
}
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') {
if (!this.cachedUrlSuffix) {
this.cachedUrlSuffix = await this.sdk.getUrlSuffix(this.region);
}
return this.cachedUrlSuffix;
}
// Try finding the ref in the passed in parameters
const parameterTarget = this.context[logicalId];
if (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) {
return defaultParameterValue;
}
// if it's not a Parameter, we need to search in the current Stack resources
return this.findGetAttTarget(logicalId);
}
private async findGetAttTarget(logicalId: string, attribute?: string): Promise<string | undefined> {
// 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);
}
const stackResources = await this.stackResources.listStackResources();
const foundResource = stackResources.find((sr) => sr.LogicalResourceId === logicalId);
if (!foundResource) {
return undefined;
}
if (foundResource.ResourceType == 'AWS::CloudFormation::Stack' && attribute?.startsWith('Outputs.')) {
const dependantStack = this.findNestedStack(logicalId, this.nestedStacks);
if (!dependantStack || !dependantStack.physicalName) {
// this is a newly created nested stack and cannot be hotswapped
return undefined;
}
const evaluateCfnTemplate = await this.createNestedEvaluateCloudFormationTemplate(
dependantStack.physicalName,
dependantStack.generatedTemplate,
dependantStack.generatedTemplate.Parameters!,
);
// Split Outputs.<refName> into 'Outputs' and '<refName>' and recursively call evaluate
return evaluateCfnTemplate.evaluateCfnExpression({
'Fn::GetAtt': attribute.split(/\.(.*)/s),
});
}
// now, we need to format the appropriate identifier depending on the resource type,
// and the requested attribute name
return this.formatResourceAttribute(foundResource, attribute);
}
private findNestedStack(
logicalId: string,
nestedStacks: {
[nestedStackLogicalId: string]: NestedStackTemplates;
},
): NestedStackTemplates | undefined {
for (const nestedStackLogicalId of Object.keys(nestedStacks)) {
if (nestedStackLogicalId === logicalId) {
return nestedStacks[nestedStackLogicalId];
}
const checkInNestedChildStacks = this.findNestedStack(
logicalId,
nestedStacks[nestedStackLogicalId].nestedStackTemplates,
);
if (checkInNestedChildStacks) return checkInNestedChildStacks;
}
return undefined;
}
private formatResourceAttribute(resource: StackResourceSummary, attribute: string | undefined): string | undefined {
const physicalId = resource.PhysicalResourceId;
// no attribute means Ref expression, for which we use the physical ID directly
if (!attribute) {
return physicalId;
}
const resourceTypeFormats = RESOURCE_TYPE_ATTRIBUTES_FORMATS[resource.ResourceType!];
if (!resourceTypeFormats) {
throw new CfnEvaluationException(
`We don't support attributes of the '${resource.ResourceType}' resource. This is a CDK limitation. ` +
'Please report it at https://github.com/aws/aws-cdk/issues/new/choose',
);
}
const attributeFmtFunc = resourceTypeFormats[attribute];
if (!attributeFmtFunc) {
throw new CfnEvaluationException(
`We don't support the '${attribute}' attribute of the '${resource.ResourceType}' resource. This is a CDK limitation. ` +
'Please report it at https://github.com/aws/aws-cdk/issues/new/choose',
);
}
const service = this.getServiceOfResource(resource);
const resourceTypeArnPart = this.getResourceTypeArnPartOfResource(resource);
return attributeFmtFunc({
partition: this.partition,
service,
region: this.region,
account: this.account,
resourceType: resourceTypeArnPart,
resourceName: physicalId!,
});
}
private getServiceOfResource(resource: StackResourceSummary): string {
return resource.ResourceType!.split('::')[1].toLowerCase();
}
private getResourceTypeArnPartOfResource(resource: StackResourceSummary): string {
const resourceType = resource.ResourceType!;
const specialCaseResourceType = RESOURCE_TYPE_SPECIAL_NAMES[resourceType]?.resourceType;
return specialCaseResourceType
? specialCaseResourceType
: // this is the default case
resourceType.split('::')[2].toLowerCase();
}
}
interface ArnParts {
readonly partition: string;
readonly service: string;
readonly region: string;
readonly account: string;
readonly resourceType: string;
readonly resourceName: string;
}
/**
* Usually, we deduce the names of the service and the resource type used to format the ARN from the CloudFormation resource type.
* For a CFN type like AWS::Service::ResourceType, the second segment becomes the service name, and the third the resource type
* (after converting both of them to lowercase).
* However, some resource types break this simple convention, and we need to special-case them.
* This map is for storing those cases.
*/
const RESOURCE_TYPE_SPECIAL_NAMES: {
[type: string]: { resourceType: string };
} = {
'AWS::Events::EventBus': {
resourceType: 'event-bus',
},
};
const RESOURCE_TYPE_ATTRIBUTES_FORMATS: {
[type: string]: { [attribute: string]: (parts: ArnParts) => string };
} = {
'AWS::IAM::Role': { Arn: iamArnFmt },
'AWS::IAM::User': { Arn: iamArnFmt },
'AWS::IAM::Group': { Arn: iamArnFmt },
'AWS::S3::Bucket': { Arn: s3ArnFmt },
'AWS::Lambda::Function': { Arn: stdColonResourceArnFmt },
'AWS::Events::EventBus': {
Arn: stdSlashResourceArnFmt,
// the name attribute of the EventBus is the same as the Ref
Name: (parts) => parts.resourceName,
},
'AWS::DynamoDB::Table': { Arn: stdSlashResourceArnFmt },
'AWS::AppSync::GraphQLApi': { ApiId: appsyncGraphQlApiApiIdFmt },
'AWS::AppSync::FunctionConfiguration': {
FunctionId: appsyncGraphQlFunctionIDFmt,
},
'AWS::AppSync::DataSource': { Name: appsyncGraphQlDataSourceNameFmt },
'AWS::KMS::Key': { Arn: stdSlashResourceArnFmt },
};
function iamArnFmt(parts: ArnParts): string {
// we skip region for IAM resources
return `arn:${parts.partition}:${parts.service}::${parts.account}:${parts.resourceType}/${parts.resourceName}`;
}
function s3ArnFmt(parts: ArnParts): string {
// we skip account, region and resourceType for S3 resources
return `arn:${parts.partition}:${parts.service}:::${parts.resourceName}`;
}
function stdColonResourceArnFmt(parts: ArnParts): string {
// this is a standard format for ARNs like: arn:aws:service:region:account:resourceType:resourceName
return `arn:${parts.partition}:${parts.service}:${parts.region}:${parts.account}:${parts.resourceType}:${parts.resourceName}`;
}
function stdSlashResourceArnFmt(parts: ArnParts): string {
// this is a standard format for ARNs like: arn:aws:service:region:account:resourceType/resourceName
return `arn:${parts.partition}:${parts.service}:${parts.region}:${parts.account}:${parts.resourceType}/${parts.resourceName}`;
}
function appsyncGraphQlApiApiIdFmt(parts: ArnParts): string {
// arn:aws:appsync:us-east-1:111111111111:apis/<apiId>
return parts.resourceName.split('/')[1];
}
function appsyncGraphQlFunctionIDFmt(parts: ArnParts): string {
// arn:aws:appsync:us-east-1:111111111111:apis/<apiId>/functions/<functionId>
return parts.resourceName.split('/')[3];
}
function appsyncGraphQlDataSourceNameFmt(parts: ArnParts): string {
// arn:aws:appsync:us-east-1:111111111111:apis/<apiId>/datasources/<name>
return parts.resourceName.split('/')[3];
}
interface Intrinsic {
readonly name: string;
readonly args: any;
}
async function asyncGlobalReplace(str: string, regex: RegExp, cb: (x: string) => Promise<string>): Promise<string> {
if (!regex.global) {
throw new ToolkitError('Regex must be created with /g flag');
}
const ret = new Array<string>();
let start = 0;
while (true) {
const match = regex.exec(str);
if (!match) {
break;
}
ret.push(str.substring(start, match.index));
ret.push(await cb(match[1]));
start = regex.lastIndex;
}
ret.push(str.slice(start));
return ret.join('');
}