packages/aws-cdk-lib/core/lib/helpers-internal/cfn-parse.ts (637 lines of code) (raw):

import { CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS } from '../../../cx-api'; import { CfnCondition } from '../cfn-condition'; import { CfnElement } from '../cfn-element'; import { Fn } from '../cfn-fn'; import { CfnMapping } from '../cfn-mapping'; import { Aws } from '../cfn-pseudo'; import { CfnResource } from '../cfn-resource'; import { CfnAutoScalingReplacingUpdate, CfnAutoScalingRollingUpdate, CfnAutoScalingScheduledAction, CfnCodeDeployLambdaAliasUpdate, CfnCreationPolicy, CfnDeletionPolicy, CfnResourceAutoScalingCreationPolicy, CfnResourceSignal, CfnUpdatePolicy, } from '../cfn-resource-policy'; import { CfnTag } from '../cfn-tag'; import { FeatureFlags } from '../feature-flags'; import { Lazy } from '../lazy'; import { CfnReference, ReferenceRendering } from '../private/cfn-reference'; import { IResolvable } from '../resolvable'; import { Validator } from '../runtime'; import { Stack } from '../stack'; import { isResolvableObject, Token } from '../token'; import { undefinedIfAllValuesAreEmpty } from '../util'; /** * The class used as the intermediate result from the generated L1 methods * that convert from CloudFormation's UpperCase to CDK's lowerCase property names. * Saves any extra properties that were present in the argument object, * but that were not found in the CFN schema, * so that they're not lost from the final CDK-rendered template. */ export class FromCloudFormationResult<T> { public readonly value: T; public readonly extraProperties: { [key: string]: any }; public constructor(value: T) { this.value = value; this.extraProperties = {}; } public appendExtraProperties(prefix: string, properties: { [key: string]: any } | undefined): void { for (const [key, val] of Object.entries(properties ?? {})) { this.extraProperties[`${prefix}.${key}`] = val; } } } /** * A property object we will accumulate properties into */ export class FromCloudFormationPropertyObject<T extends Record<string, any>> extends FromCloudFormationResult<T> { private readonly recognizedProperties = new Set<string>(); public constructor() { super({} as any); // We're still accumulating } /** * Add a parse result under a given key */ public addPropertyResult(cdkPropName: keyof T, cfnPropName: string, result?: FromCloudFormationResult<any>): void { this.recognizedProperties.add(cfnPropName); if (!result) { return; } this.value[cdkPropName] = result.value; this.appendExtraProperties(cfnPropName, result.extraProperties); } public addUnrecognizedPropertiesAsExtra(properties: object): void { for (const [key, val] of Object.entries(properties)) { if (!this.recognizedProperties.has(key)) { this.extraProperties[key] = val; } } } } /** * This class contains static methods called when going from * translated values received from `CfnParser.parseValue` * to the actual L1 properties - * things like changing IResolvable to the appropriate type * (string, string array, or number), etc. * * While this file not exported from the module * (to not make it part of the public API), * it is directly referenced in the generated L1 code. * */ export class FromCloudFormation { // nothing to for any but return it public static getAny(value: any): FromCloudFormationResult<any> { return new FromCloudFormationResult(value); } public static getBoolean(this: void, value: any): FromCloudFormationResult<boolean | IResolvable> { if (typeof value === 'string') { // CloudFormation allows passing strings as boolean switch (value) { case 'true': return new FromCloudFormationResult(true); case 'false': return new FromCloudFormationResult(false); default: throw new Error(`Expected 'true' or 'false' for boolean value, got: '${value}'`); } } // in all other cases, just return the value, // and let a validator handle if it's not a boolean return new FromCloudFormationResult(value); } public static getDate(value: any): FromCloudFormationResult<Date | IResolvable> { // if the date is a deploy-time value, just return it if (isResolvableObject(value)) { return new FromCloudFormationResult(value); } // if the date has been given as a string, convert it if (typeof value === 'string') { return new FromCloudFormationResult(new Date(value)); } // all other cases - just return the value, // if it's not a Date, a validator should catch it return new FromCloudFormationResult(value); } // won't always return a string; if the input can't be resolved to a string, // the input will be returned. public static getString(this: void, value: any): FromCloudFormationResult<string> { // if the string is a deploy-time value, serialize it to a Token if (isResolvableObject(value)) { return new FromCloudFormationResult(value.toString()); } // CloudFormation treats numbers and strings interchangeably; // so, if we get a number here, convert it to a string if (typeof value === 'number') { return new FromCloudFormationResult(value.toString()); } // CloudFormation treats booleans and strings interchangeably; // so, if we get a boolean here, convert it to a string if (typeof value === 'boolean') { return new FromCloudFormationResult(value.toString()); } // in all other cases, just return the input, // and let a validator handle it if it's not a string return new FromCloudFormationResult(value); } // won't always return a number; if the input can't be parsed to a number, // the input will be returned. public static getNumber(this: void, value: any): FromCloudFormationResult<number> { // if the string is a deploy-time value, serialize it to a Token if (isResolvableObject(value)) { return new FromCloudFormationResult(Token.asNumber(value)); } // return a number, if the input can be parsed as one if (typeof value === 'string') { const parsedValue = parseFloat(value); if (!isNaN(parsedValue)) { return new FromCloudFormationResult(parsedValue); } } // otherwise return the input, // and let a validator handle it if it's not a number return new FromCloudFormationResult(value); } public static getStringArray(this: void, value: any): FromCloudFormationResult<string[]> { // if the array is a deploy-time value, serialize it to a Token if (isResolvableObject(value)) { return new FromCloudFormationResult(Token.asList(value)); } // in all other cases, delegate to the standard mapping logic return FromCloudFormation.getArray(FromCloudFormation.getString)(value); } public static getArray<T>(this: void, mapper: (arg: any) => FromCloudFormationResult<T>): (x: any) => FromCloudFormationResult<T[]> { return (value: any) => { if (!Array.isArray(value)) { // break the type system, and just return the given value, // which hopefully will be reported as invalid by the validator // of the property we're transforming // (unless it's a deploy-time value, // which we can't map over at build time anyway) return new FromCloudFormationResult(value); } const values = new Array<any>(); const ret = new FromCloudFormationResult(values); for (let i = 0; i < value.length; i++) { const result = mapper(value[i]); values.push(result.value); ret.appendExtraProperties(`${i}`, result.extraProperties); } return ret; }; } public static getMap<T>(this: void, mapper: (arg: any) => FromCloudFormationResult<T>): (x: any) => FromCloudFormationResult<{ [key: string]: T }> { return (value: any) => { if (typeof value !== 'object') { // if the input is not a map (= object in JS land), // just return it, and let the validator of this property handle it // (unless it's a deploy-time value, // which we can't map over at build time anyway) return new FromCloudFormationResult(value); } const values: { [key: string]: T } = {}; const ret = new FromCloudFormationResult(values); for (const [key, val] of Object.entries(value)) { const result = mapper(val); values[key] = result.value; ret.appendExtraProperties(key, result.extraProperties); } return ret; }; } public static getCfnTag(this: void, tag: any): FromCloudFormationResult<CfnTag | IResolvable> { if (isResolvableObject(tag)) { return new FromCloudFormationResult(tag); } return tag == null ? new FromCloudFormationResult({ } as any) // break the type system - this should be detected at runtime by a tag validator : new FromCloudFormationResult({ key: tag.Key, value: tag.Value, }); } /** * Return a function that, when applied to a value, will return the first validly deserialized one */ public static getTypeUnion(this: void, validators: Validator[], mappers: Array<(x: any) => FromCloudFormationResult<any>>): (x: any) => FromCloudFormationResult<any> { return (value: any) => { for (let i = 0; i < validators.length; i++) { const candidate = mappers[i](value); if (validators[i](candidate.value).isSuccess) { return candidate; } } // if nothing matches, just return the input unchanged, and let validators catch it return new FromCloudFormationResult(value); }; } } /** * An interface that represents callbacks into a CloudFormation template. * Used by the fromCloudFormation methods in the generated L1 classes. */ export interface ICfnFinder { /** * Return the Condition with the given name from the template. * If there is no Condition with that name in the template, * returns undefined. */ findCondition(conditionName: string): CfnCondition | undefined; /** * Return the Mapping with the given name from the template. * If there is no Mapping with that name in the template, * returns undefined. */ findMapping(mappingName: string): CfnMapping | undefined; /** * Returns the element referenced using a Ref expression with the given name. * If there is no element with this name in the template, * return undefined. */ findRefTarget(elementName: string): CfnElement | undefined; /** * Returns the resource with the given logical ID in the template. * If a resource with that logical ID was not found in the template, * returns undefined. */ findResource(logicalId: string): CfnResource | undefined; } /** * The interface used as the last argument to the fromCloudFormation * static method of the generated L1 classes. */ export interface FromCloudFormationOptions { /** * The parser used to convert CloudFormation to values the CDK understands. */ readonly parser: CfnParser; } /** * The context in which the parsing is taking place. * * Some fragments of CloudFormation templates behave differently than others * (for example, the 'Conditions' sections treats { "Condition": "NameOfCond" } * differently than the 'Resources' section). * This enum can be used to change the created `CfnParser` behavior, * based on the template context. */ export enum CfnParsingContext { /** We're currently parsing the 'Conditions' section. */ CONDITIONS, /** We're currently parsing the 'Rules' section. */ RULES, } /** * The options for `FromCloudFormation.parseValue`. */ export interface ParseCfnOptions { /** * The finder interface used to resolve references in the template. */ readonly finder: ICfnFinder; /** * The context we're parsing the template in. * * @default - the default context (no special behavior) */ readonly context?: CfnParsingContext; /** * Values provided here will replace references to parameters in the parsed template. */ readonly parameters: { [parameterName: string]: any }; } /** * This class contains methods for translating from a pure CFN value * (like a JS object { "Ref": "Bucket" }) * to a form CDK understands * (like Fn.ref('Bucket')). * * While this file not exported from the module * (to not make it part of the public API), * it is directly referenced in the generated L1 code, * so any renames of it need to be reflected in codegen as well. * */ export class CfnParser { private readonly options: ParseCfnOptions; private stack?: Stack; constructor(options: ParseCfnOptions) { this.options = options; } public handleAttributes(resource: CfnResource, resourceAttributes: any, logicalId: string): void { const cfnOptions = resource.cfnOptions; this.stack = Stack.of(resource); const creationPolicy = this.parseCreationPolicy(resourceAttributes.CreationPolicy, logicalId); const updatePolicy = this.parseUpdatePolicy(resourceAttributes.UpdatePolicy, logicalId); cfnOptions.creationPolicy = creationPolicy.value; cfnOptions.updatePolicy = updatePolicy.value; cfnOptions.deletionPolicy = this.parseDeletionPolicy(resourceAttributes.DeletionPolicy); cfnOptions.updateReplacePolicy = this.parseDeletionPolicy(resourceAttributes.UpdateReplacePolicy); cfnOptions.version = this.parseValue(resourceAttributes.Version); cfnOptions.description = this.parseValue(resourceAttributes.Description); cfnOptions.metadata = this.parseValue(resourceAttributes.Metadata); for (const [key, value] of Object.entries(creationPolicy.extraProperties)) { resource.addOverride(`CreationPolicy.${key}`, value); } for (const [key, value] of Object.entries(updatePolicy.extraProperties)) { resource.addOverride(`UpdatePolicy.${key}`, value); } // handle Condition if (resourceAttributes.Condition) { const condition = this.finder.findCondition(resourceAttributes.Condition); if (!condition) { throw new Error(`Resource '${logicalId}' uses Condition '${resourceAttributes.Condition}' that doesn't exist`); } cfnOptions.condition = condition; } // handle DependsOn resourceAttributes.DependsOn = resourceAttributes.DependsOn ?? []; const dependencies: string[] = Array.isArray(resourceAttributes.DependsOn) ? resourceAttributes.DependsOn : [resourceAttributes.DependsOn]; for (const dep of dependencies) { const depResource = this.finder.findResource(dep); if (!depResource) { throw new Error(`Resource '${logicalId}' depends on '${dep}' that doesn't exist`); } resource.node.addDependency(depResource); } } private parseCreationPolicy(policy: any, logicalId: string): FromCloudFormationResult<CfnCreationPolicy | undefined> { if (typeof policy !== 'object') { return new FromCloudFormationResult(undefined); } this.throwIfIsIntrinsic(policy, logicalId); const self = this; const creaPol = new ObjectParser<CfnCreationPolicy>(this.parseValue(policy)); creaPol.parseCase('autoScalingCreationPolicy', parseAutoScalingCreationPolicy); creaPol.parseCase('resourceSignal', parseResourceSignal); return creaPol.toResult(); function parseAutoScalingCreationPolicy(p: any): FromCloudFormationResult<CfnResourceAutoScalingCreationPolicy | undefined> { self.throwIfIsIntrinsic(p, logicalId); if (typeof p !== 'object') { return new FromCloudFormationResult(undefined); } const autoPol = new ObjectParser<CfnResourceAutoScalingCreationPolicy>(p); autoPol.parseCase('minSuccessfulInstancesPercent', FromCloudFormation.getNumber); return autoPol.toResult(); } function parseResourceSignal(p: any): FromCloudFormationResult<CfnResourceSignal | undefined> { if (typeof p !== 'object') { return new FromCloudFormationResult(undefined); } self.throwIfIsIntrinsic(p, logicalId); const sig = new ObjectParser<CfnResourceSignal>(p); sig.parseCase('count', FromCloudFormation.getNumber); sig.parseCase('timeout', FromCloudFormation.getString); return sig.toResult(); } } private parseUpdatePolicy(policy: any, logicalId: string): FromCloudFormationResult<CfnUpdatePolicy | undefined> { if (typeof policy !== 'object') { return new FromCloudFormationResult(undefined); } this.throwIfIsIntrinsic(policy, logicalId); const self = this; // change simple JS values to their CDK equivalents const uppol = new ObjectParser<CfnUpdatePolicy>(this.parseValue(policy)); uppol.parseCase('autoScalingReplacingUpdate', parseAutoScalingReplacingUpdate); uppol.parseCase('autoScalingRollingUpdate', parseAutoScalingRollingUpdate); uppol.parseCase('autoScalingScheduledAction', parseAutoScalingScheduledAction); uppol.parseCase('codeDeployLambdaAliasUpdate', parseCodeDeployLambdaAliasUpdate); uppol.parseCase('enableVersionUpgrade', (x) => FromCloudFormation.getBoolean(x) as any); uppol.parseCase('useOnlineResharding', (x) => FromCloudFormation.getBoolean(x) as any); return uppol.toResult(); function parseAutoScalingReplacingUpdate(p: any): FromCloudFormationResult<CfnAutoScalingReplacingUpdate | undefined> { if (typeof p !== 'object') { return new FromCloudFormationResult(undefined); } self.throwIfIsIntrinsic(p, logicalId); const repUp = new ObjectParser<CfnAutoScalingReplacingUpdate>(p); repUp.parseCase('willReplace', (x) => x); return repUp.toResult(); } function parseAutoScalingRollingUpdate(p: any): FromCloudFormationResult<CfnAutoScalingRollingUpdate | undefined> { if (typeof p !== 'object') { return new FromCloudFormationResult(undefined); } self.throwIfIsIntrinsic(p, logicalId); const rollUp = new ObjectParser<CfnAutoScalingRollingUpdate>(p); rollUp.parseCase('maxBatchSize', FromCloudFormation.getNumber); rollUp.parseCase('minInstancesInService', FromCloudFormation.getNumber); rollUp.parseCase('minSuccessfulInstancesPercent', FromCloudFormation.getNumber); rollUp.parseCase('minActiveInstancesPercent', FromCloudFormation.getNumber); rollUp.parseCase('pauseTime', FromCloudFormation.getString); rollUp.parseCase('suspendProcesses', FromCloudFormation.getStringArray); rollUp.parseCase('waitOnResourceSignals', FromCloudFormation.getBoolean); return rollUp.toResult(); } function parseCodeDeployLambdaAliasUpdate(p: any): FromCloudFormationResult<CfnCodeDeployLambdaAliasUpdate | undefined> { if (typeof p !== 'object') { return new FromCloudFormationResult(undefined); } self.throwIfIsIntrinsic(p, logicalId); const cdUp = new ObjectParser<CfnCodeDeployLambdaAliasUpdate>(p); cdUp.parseCase('beforeAllowTrafficHook', FromCloudFormation.getString); cdUp.parseCase('afterAllowTrafficHook', FromCloudFormation.getString); cdUp.parseCase('applicationName', FromCloudFormation.getString); cdUp.parseCase('deploymentGroupName', FromCloudFormation.getString); return cdUp.toResult(); } function parseAutoScalingScheduledAction(p: any): FromCloudFormationResult<CfnAutoScalingScheduledAction | undefined> { if (typeof p !== 'object') { return new FromCloudFormationResult(undefined); } self.throwIfIsIntrinsic(p, logicalId); const schedUp = new ObjectParser<CfnAutoScalingScheduledAction>(p); schedUp.parseCase('ignoreUnmodifiedGroupSizeProperties', FromCloudFormation.getBoolean); return schedUp.toResult(); } } private parseDeletionPolicy(policy: any): CfnDeletionPolicy | undefined { if (policy === undefined || policy === null) { return undefined; } const isIntrinsic = this.looksLikeCfnIntrinsic(policy); switch (policy) { case 'Delete': return CfnDeletionPolicy.DELETE; case 'Retain': return CfnDeletionPolicy.RETAIN; case 'Snapshot': return CfnDeletionPolicy.SNAPSHOT; case 'RetainExceptOnCreate': return CfnDeletionPolicy.RETAIN_EXCEPT_ON_CREATE; default: if (isIntrinsic) { policy = this.parseValue(policy); return policy; } else { throw new Error(`Unrecognized DeletionPolicy '${policy}'`); } } } public parseValue(cfnValue: any): any { // == null captures undefined as well if (cfnValue == null) { return undefined; } // if we have any late-bound values, // just return them if (isResolvableObject(cfnValue)) { return cfnValue; } if (Array.isArray(cfnValue)) { return cfnValue.map(el => this.parseValue(el)); } if (typeof cfnValue === 'object') { // an object can be either a CFN intrinsic, or an actual object const cfnIntrinsic = this.parseIfCfnIntrinsic(cfnValue); if (cfnIntrinsic !== undefined) { return cfnIntrinsic; } const ret: any = {}; for (const [key, val] of Object.entries(cfnValue)) { ret[key] = this.parseValue(val); } return ret; } // in all other cases, just return the input return cfnValue; } public get finder(): ICfnFinder { return this.options.finder; } private parseIfCfnIntrinsic(object: any): any { const key = this.looksLikeCfnIntrinsic(object); switch (key) { case undefined: return undefined; case 'Ref': { const refTarget = object[key]; const specialRef = this.specialCaseRefs(refTarget); if (specialRef !== undefined) { return specialRef; } else { const refElement = this.finder.findRefTarget(refTarget); if (!refElement) { throw new Error(`Element used in Ref expression with logical ID: '${refTarget}' not found`); } return CfnReference.for(refElement, 'Ref'); } } case 'Fn::GetAtt': { const value = object[key]; let logicalId: string, attributeName: string, stringForm: boolean; // Fn::GetAtt takes as arguments either a string... if (typeof value === 'string') { // ...in which case the logical ID and the attribute name are separated with '.' const dotIndex = value.indexOf('.'); if (dotIndex === -1) { throw new Error(`Short-form Fn::GetAtt must contain a '.' in its string argument, got: '${value}'`); } logicalId = value.slice(0, dotIndex); attributeName = value.slice(dotIndex + 1); // the +1 is to skip the actual '.' stringForm = true; } else { // ...or a 2-element list logicalId = value[0]; attributeName = value[1]; stringForm = false; } const target = this.finder.findResource(logicalId); if (!target) { throw new Error(`Resource used in GetAtt expression with logical ID: '${logicalId}' not found`); } return CfnReference.for(target, attributeName, stringForm ? ReferenceRendering.GET_ATT_STRING : undefined); } case 'Fn::Join': { // Fn::Join takes a 2-element list as its argument, // where the first element is the delimiter, // and the second is the list of elements to join const value = this.parseValue(object[key]); // wrap the array as a Token, // as otherwise Fn.join() will try to concatenate // the non-token parts, // causing a diff with the original template return Fn.join(value[0], Lazy.list({ produce: () => value[1] })); } case 'Fn::Cidr': { const value = this.parseValue(object[key]); return Fn.cidr(value[0], value[1], value[2]); } case 'Fn::FindInMap': { const value = this.parseValue(object[key]); // the first argument to FindInMap is the mapping name let mappingName: string; if (Token.isUnresolved(value[0])) { // the first argument can be a dynamic expression like Ref: Param; // if it is, we can't find the mapping in advance mappingName = value[0]; } else { const mapping = this.finder.findMapping(value[0]); if (!mapping) { throw new Error(`Mapping used in FindInMap expression with name '${value[0]}' was not found in the template`); } mappingName = mapping.logicalId; } return Fn._findInMap(mappingName, value[1], value[2]); } case 'Fn::Select': { const value = this.parseValue(object[key]); return Fn.select(value[0], value[1]); } case 'Fn::GetAZs': { const value = this.parseValue(object[key]); return Fn.getAzs(value); } case 'Fn::ImportValue': { const value = this.parseValue(object[key]); return Fn.importValue(value); } case 'Fn::Split': { const value = this.parseValue(object[key]); return Fn.split(value[0], value[1]); } case 'Fn::Transform': { const value = this.parseValue(object[key]); return Fn.transform(value.Name, value.Parameters); } case 'Fn::Base64': { const value = this.parseValue(object[key]); return Fn.base64(value); } case 'Fn::If': { // Fn::If takes a 3-element list as its argument, // where the first element is the name of a Condition const value = this.parseValue(object[key]); const condition = this.finder.findCondition(value[0]); if (!condition) { throw new Error(`Condition '${value[0]}' used in an Fn::If expression does not exist in the template`); } return Fn.conditionIf(condition.logicalId, value[1], value[2]); } case 'Fn::Equals': { const value = this.parseValue(object[key]); return Fn.conditionEquals(value[0], value[1]); } case 'Fn::And': { const value = this.parseValue(object[key]); return Fn.conditionAnd(...value); } case 'Fn::Not': { const value = this.parseValue(object[key]); return Fn.conditionNot(value[0]); } case 'Fn::Or': { const value = this.parseValue(object[key]); return Fn.conditionOr(...value); } case 'Fn::Sub': { const value = this.parseValue(object[key]); let fnSubString: string; let map: { [key: string]: any } | undefined; if (typeof value === 'string') { fnSubString = value; map = undefined; } else { fnSubString = value[0]; map = value[1]; } return this.parseFnSubString(fnSubString, map); } case 'Condition': { // a reference to a Condition from another Condition const condition = this.finder.findCondition(object[key]); if (!condition) { throw new Error(`Referenced Condition with name '${object[key]}' was not found in the template`); } return { Condition: condition.logicalId }; } default: if (this.options.context === CfnParsingContext.RULES) { return this.handleRulesIntrinsic(key, object); } else { throw new Error(`Unsupported CloudFormation function '${key}'`); } } } private throwIfIsIntrinsic(object: object, logicalId: string): void { // Top-level parsing functions check before we call `parseValue`, which requires // calling `looksLikeCfnIntrinsic`. Helper parsing functions check after we call // `parseValue`, which requires calling `isResolvableObject`. if (!this.stack) { throw new Error('cannot call this method before handleAttributes!'); } if (FeatureFlags.of(this.stack).isEnabled(CFN_INCLUDE_REJECT_COMPLEX_RESOURCE_UPDATE_CREATE_POLICY_INTRINSICS)) { if (isResolvableObject(object ?? {}) || this.looksLikeCfnIntrinsic(object ?? {})) { throw new Error(`Cannot convert resource '${logicalId}' to CDK objects: it uses an intrinsic in a resource update or deletion policy to represent a non-primitive value. Specify '${logicalId}' in the 'dehydratedResources' prop to skip parsing this resource, while still including it in the output.`); } } } private looksLikeCfnIntrinsic(object: object): string | undefined { const objectKeys = Object.keys(object); // a CFN intrinsic is always an object with a single key if (objectKeys.length !== 1) { return undefined; } const key = objectKeys[0]; return key === 'Ref' || key.startsWith('Fn::') || // special intrinsic only available in the 'Conditions' section (this.options.context === CfnParsingContext.CONDITIONS && key === 'Condition') ? key : undefined; } private parseFnSubString(templateString: string, expressionMap: { [key: string]: any } | undefined): string { const map = expressionMap ?? {}; const self = this; return Fn.sub(go(templateString), Object.keys(map).length === 0 ? expressionMap : map); function go(value: string): string { const leftBrace = value.indexOf('${'); if (leftBrace === -1) { return value; } // search for the closing brace to the right of the opening '${' // (in theory, there could be other braces in the string, // for example if it represents a JSON object) const rightBrace = value.indexOf('}', leftBrace); if (rightBrace === -1) { return value; } const leftHalf = value.substring(0, leftBrace); const rightHalf = value.substring(rightBrace + 1); // don't include left and right braces when searching for the target of the reference const refTarget = value.substring(leftBrace + 2, rightBrace).trim(); if (refTarget[0] === '!') { return value.substring(0, rightBrace + 1) + go(rightHalf); } // lookup in map if (refTarget in map) { return leftHalf + '${' + refTarget + '}' + go(rightHalf); } // since it's not in the map, check if it's a pseudo-parameter // (or a value to be substituted for a Parameter, provided by the customer) const specialRef = self.specialCaseSubRefs(refTarget); if (specialRef !== undefined) { if (Token.isUnresolved(specialRef)) { // specialRef can only be a Token if the value passed by the customer // for substituting a Parameter was a Token. // This is actually bad here, // because the Token can potentially be something that doesn't render // well inside an Fn::Sub template string, like a { Ref } object. // To handle this case, // instead of substituting the Parameter directly with the token in the template string, // add a new entry to the Fn::Sub map, // with key refTarget, and the token as the value. // This is safe, because this sort of shadowing is legal in CloudFormation, // and also because we're certain the Fn::Sub map doesn't contain an entry for refTarget // (as we check that condition in the code right above this). map[refTarget] = specialRef; return leftHalf + '${' + refTarget + '}' + go(rightHalf); } else { return leftHalf + specialRef + go(rightHalf); } } const dotIndex = refTarget.indexOf('.'); const isRef = dotIndex === -1; if (isRef) { const refElement = self.finder.findRefTarget(refTarget); if (!refElement) { throw new Error(`Element referenced in Fn::Sub expression with logical ID: '${refTarget}' was not found in the template`); } return leftHalf + CfnReference.for(refElement, 'Ref', ReferenceRendering.FN_SUB).toString() + go(rightHalf); } else { const targetId = refTarget.substring(0, dotIndex); const refResource = self.finder.findResource(targetId); if (!refResource) { throw new Error(`Resource referenced in Fn::Sub expression with logical ID: '${targetId}' was not found in the template`); } const attribute = refTarget.substring(dotIndex + 1); return leftHalf + CfnReference.for(refResource, attribute, ReferenceRendering.FN_SUB).toString() + go(rightHalf); } } } private handleRulesIntrinsic(key: string, object: any): any { // Rules have their own set of intrinsics: // https://docs.aws.amazon.com/servicecatalog/latest/adminguide/intrinsic-function-reference-rules.html switch (key) { case 'Fn::ValueOf': { // ValueOf is special, // as it takes the name of a Parameter as its first argument const value = this.parseValue(object[key]); const parameterName = value[0]; if (parameterName in this.parameters) { // since ValueOf returns the value of a specific attribute, // fail here - this substitution is not allowed throw new Error(`Cannot substitute parameter '${parameterName}' used in Fn::ValueOf expression with attribute '${value[1]}'`); } const param = this.finder.findRefTarget(parameterName); if (!param) { throw new Error(`Rule references parameter '${parameterName}' which was not found in the template`); } // create an explicit IResolvable, // as Fn.valueOf() returns a string, // which is incorrect // (Fn::ValueOf can also return an array) return Lazy.any({ produce: () => ({ 'Fn::ValueOf': [param.logicalId, value[1]] }) }); } default: // I don't want to hard-code the list of supported Rules-specific intrinsics in this function; // so, just return undefined here, // and they will be treated as a regular JSON object return undefined; } } private specialCaseRefs(value: any): any { if (value in this.parameters) { return this.parameters[value]; } switch (value) { case 'AWS::AccountId': return Aws.ACCOUNT_ID; case 'AWS::Region': return Aws.REGION; case 'AWS::Partition': return Aws.PARTITION; case 'AWS::URLSuffix': return Aws.URL_SUFFIX; case 'AWS::NotificationARNs': return Aws.NOTIFICATION_ARNS; case 'AWS::StackId': return Aws.STACK_ID; case 'AWS::StackName': return Aws.STACK_NAME; case 'AWS::NoValue': return Aws.NO_VALUE; default: return undefined; } } private specialCaseSubRefs(value: string): string | undefined { if (value in this.parameters) { return this.parameters[value]; } return value.indexOf('::') === -1 ? undefined: '${' + value + '}'; } private get parameters(): { [parameterName: string]: any } { return this.options.parameters || {}; } } class ObjectParser<T extends object> { private readonly parsed: Record<string, unknown> = {}; private readonly unparsed: Record<string, unknown> = {}; constructor(input: Record<string, unknown>) { this.unparsed = { ...input }; } /** * Parse a single field from the object into the target object * * The source key will be assumed to be the exact same as the * target key, but with an uppercase first letter. */ public parseCase<K extends keyof T>(targetKey: K, parser: (x: any) => T[K] | FromCloudFormationResult<T[K] | IResolvable>) { const sourceKey = ucfirst(String(targetKey)); this.parse(targetKey, sourceKey, parser); } public parse<K extends keyof T>(targetKey: K, sourceKey: string, parser: (x: any) => T[K] | FromCloudFormationResult<T[K] | IResolvable>) { if (!(sourceKey in this.unparsed)) { return; } const value = parser(this.unparsed[sourceKey]); delete this.unparsed[sourceKey]; if (value instanceof FromCloudFormationResult) { for (const [k, v] of Object.entries(value.extraProperties ?? {})) { this.unparsed[`${sourceKey}.${k}`] = v; } this.parsed[targetKey as any] = value.value; } else { this.parsed[targetKey as any] = value; } } public toResult(): FromCloudFormationResult<T | undefined> { const ret = new FromCloudFormationResult(undefinedIfAllValuesAreEmpty(this.parsed as any)); for (const [k, v] of Object.entries(this.unparsedKeys)) { ret.extraProperties[k] = v; } return ret; } private get unparsedKeys(): Record<string, unknown> { const unparsed = { ...this.unparsed }; for (const [k, v] of Object.entries(this.unparsed)) { if (v instanceof FromCloudFormationResult) { for (const [k2, v2] of Object.entries(v.extraProperties ?? {})) { unparsed[`${k}.${k2}`] = v2; } } else { unparsed[k] = v; } } return unparsed; } } function ucfirst(x: string) { return x.slice(0, 1).toUpperCase() + x.slice(1); }