diff --git a/packages/aws-cdk/lib/api/deploy-stack.ts b/packages/aws-cdk/lib/api/deploy-stack.ts index b0770ebdf8ea7..a889e0c802fc4 100644 --- a/packages/aws-cdk/lib/api/deploy-stack.ts +++ b/packages/aws-cdk/lib/api/deploy-stack.ts @@ -10,6 +10,7 @@ import { publishAssets } from '../util/asset-publishing'; import { contentHash } from '../util/content-hash'; import { ISDK, SdkProvider } from './aws-auth'; import { tryHotswapDeployment } from './hotswap-deployments'; +import { CfnEvaluationException } from './hotswap/evaluate-cloudformation-template'; import { ToolkitInfo } from './toolkit-info'; import { changeSetHasNoChanges, CloudFormationStack, TemplateParameters, waitForChangeSet, @@ -252,12 +253,19 @@ export async function deployStack(options: DeployStackOptions): Promise { - const currentTemplate = await cloudFormationStack.template(); - const stackChanges = cfn_diff.diffTemplate(currentTemplate, stackArtifact.template); - // resolve the environment, so we can substitute things like AWS::Region in CFN expressions const resolvedEnv = await sdkProvider.resolveEnvironment(stackArtifact.environment); - const hotswappableChanges = findAllHotswappableChanges(stackChanges, { - ...assetParams, - 'AWS::Region': resolvedEnv.region, - 'AWS::AccountId': resolvedEnv.account, + // create a new SDK using the CLI credentials, because the default one will not work for new-style synthesis - + // it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions + const sdk = await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting); + // 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. + const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName); + const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({ + stackArtifact, + parameters: assetParams, + account: resolvedEnv.account, + region: resolvedEnv.region, + // ToDo make this better: + partition: 'aws', + // ToDo make this better: + urlSuffix: 'amazonaws.com', + listStackResources, }); + + const currentTemplate = await cloudFormationStack.template(); + const stackChanges = cfn_diff.diffTemplate(currentTemplate, stackArtifact.template); + const hotswappableChanges = await findAllHotswappableChanges(stackChanges, evaluateCfnTemplate); if (!hotswappableChanges) { // this means there were changes to the template that cannot be short-circuited return undefined; } - // create a new SDK using the CLI credentials, because the default one will not work for new-style synthesis - - // it assumes the bootstrap deploy Role, which doesn't have permissions to update Lambda functions - const sdk = await sdkProvider.forEnvironment(resolvedEnv, Mode.ForWriting); // apply the short-circuitable changes - await applyAllHotswappableChanges(sdk, stackArtifact, hotswappableChanges); + await applyAllHotswappableChanges(sdk, hotswappableChanges); return { noOp: hotswappableChanges.length === 0, stackArn: cloudFormationStack.stackId, outputs: cloudFormationStack.outputs, stackArtifact }; } -function findAllHotswappableChanges( - stackChanges: cfn_diff.TemplateDiff, assetParamsWithEnv: { [key: string]: string }, -): HotswapOperation[] | undefined { - const hotswappableResources = new Array(); - let foundNonHotswappableChange = false; - stackChanges.resources.forEachDifference((logicalId: string, change: cfn_diff.ResourceDifference) => { - const lambdaFunctionShortCircuitChange = isHotswappableLambdaFunctionChange(logicalId, change, assetParamsWithEnv); - if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) { - foundNonHotswappableChange = true; - } else if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.IRRELEVANT) { - // empty 'if' just for flow-aware typing to kick in... - } else { - hotswappableResources.push(lambdaFunctionShortCircuitChange); +async function findAllHotswappableChanges( + stackChanges: cfn_diff.TemplateDiff, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + const promises = new Array>(); + stackChanges.resources.forEachDifference(async (logicalId: string, change: cfn_diff.ResourceDifference) => { + promises.push(isHotswappableLambdaFunctionChange(logicalId, change, evaluateCfnTemplate)); + }); + return Promise.all(promises).then(hotswapDetectionResults => { + const hotswappableResources = new Array(); + let foundNonHotswappableChange = false; + for (const lambdaFunctionShortCircuitChange of hotswapDetectionResults) { + if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT) { + foundNonHotswappableChange = true; + } else if (lambdaFunctionShortCircuitChange === ChangeHotswapImpact.IRRELEVANT) { + // empty 'if' just for flow-aware typing to kick in... + } else { + hotswappableResources.push(lambdaFunctionShortCircuitChange); + } } + return foundNonHotswappableChange ? undefined : hotswappableResources; }); - return foundNonHotswappableChange ? undefined : hotswappableResources; } async function applyAllHotswappableChanges( - sdk: ISDK, stackArtifact: cxapi.CloudFormationStackArtifact, hotswappableChanges: HotswapOperation[], + sdk: ISDK, hotswappableChanges: HotswapOperation[], ): Promise { - // The current resources of the Stack. - // We need them to figure out the physical name of a function in case it wasn't specified by the user. - // We fetch it lazily, to save a service call, in case all updated Lambdas have their names set. - const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName); - - return Promise.all(hotswappableChanges.map(hotswapOperation => hotswapOperation.apply(sdk, listStackResources))); + return Promise.all(hotswappableChanges.map(hotswapOperation => { + return hotswapOperation.apply(sdk); + })); } class LazyListStackResources implements ListStackResources { @@ -79,12 +93,12 @@ class LazyListStackResources implements ListStackResources { async listStackResources(): Promise { if (this.stackResources === undefined) { - this.stackResources = await this.getStackResource(); + this.stackResources = await this.getStackResources(); } return this.stackResources; } - private async getStackResource(): Promise { + private async getStackResources(): Promise { const ret = new Array(); let nextToken: string | undefined; do { diff --git a/packages/aws-cdk/lib/api/hotswap/common.ts b/packages/aws-cdk/lib/api/hotswap/common.ts index d509c32b4c781..c11b29d1d7daa 100644 --- a/packages/aws-cdk/lib/api/hotswap/common.ts +++ b/packages/aws-cdk/lib/api/hotswap/common.ts @@ -1,7 +1,6 @@ import * as cfn_diff from '@aws-cdk/cloudformation-diff'; import { CloudFormation } from 'aws-sdk'; import { ISDK } from '../aws-auth'; -import { evaluateCfn } from '../util/cloudformation/evaluate-cfn'; export interface ListStackResources { listStackResources(): Promise; @@ -11,7 +10,7 @@ export interface ListStackResources { * An interface that represents a change that can be deployed in a short-circuit manner. */ export interface HotswapOperation { - apply(sdk: ISDK, stackResources: ListStackResources): Promise; + apply(sdk: ISDK): Promise; } /** @@ -34,24 +33,6 @@ export enum ChangeHotswapImpact { export type ChangeHotswapResult = HotswapOperation | ChangeHotswapImpact; -/** - * For old-style synthesis which uses CFN Parameters, - * the Code properties can have the values of complex CFN expressions. - * For new-style synthesis of env-agnostic stacks, - * the Fn::Sub expression is used for the Asset bucket. - * Evaluate the CFN expressions to concrete string values which we need for the - * updateFunctionCode() service call. - */ -export function stringifyPotentialCfnExpression(value: any, assetParamsWithEnv: { [key: string]: string }): string { - // if we already have a string, nothing to do - if (value == null || typeof value === 'string') { - return value; - } - - // otherwise, we assume this is a CloudFormation expression that we need to evaluate - return evaluateCfn(value, assetParamsWithEnv); -} - export function assetMetadataChanged(change: cfn_diff.ResourceDifference): boolean { return !!change.newValue?.Metadata['aws:asset:path']; } diff --git a/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts b/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts new file mode 100644 index 0000000000000..dc1541ed74771 --- /dev/null +++ b/packages/aws-cdk/lib/api/hotswap/evaluate-cloudformation-template.ts @@ -0,0 +1,259 @@ +import * as cxapi from '@aws-cdk/cx-api'; +import * as AWS from 'aws-sdk'; +import { ListStackResources } from './common'; + +export class CfnEvaluationException extends Error {} + +export interface EvaluateCloudFormationTemplateProps { + readonly stackArtifact: cxapi.CloudFormationStackArtifact; + readonly parameters: { [parameterName: string]: string }; + readonly account: string; + readonly region: string; + readonly partition: string; + readonly urlSuffix: string; + + readonly listStackResources: ListStackResources; +} + +export class EvaluateCloudFormationTemplate { + private readonly stackResources: ListStackResources; + private readonly context: { [k: string]: string }; + private readonly account: string; + private readonly region: string; + private readonly partition: string; + + constructor(props: EvaluateCloudFormationTemplateProps) { + this.stackResources = props.listStackResources; + this.context = { + 'AWS::AccountId': props.account, + 'AWS::Region': props.region, + 'AWS::Partition': props.partition, + 'AWS::URLSuffix': props.urlSuffix, + ...props.parameters, + }; + this.account = props.account; + this.region = props.region; + this.partition = props.partition; + } + + public async findPhysicalNameFor(logicalId: string): Promise { + const stackResources = await this.stackResources.listStackResources(); + return stackResources.find(sr => sr.LogicalResourceId === logicalId)?.PhysicalResourceId; + } + + public async evaluateCfnExpression(cfnExpression: any): Promise { + const self = this; + 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 { + const evaluatedArgs = await self.evaluateCfnExpression(args); + return evaluatedArgs.join(separator); + } + + async 'Fn::Split'(separator: string, args: any): Promise { + const evaluatedArgs = await self.evaluateCfnExpression(args); + return evaluatedArgs.split(separator); + } + + async 'Fn::Select'(index: number, args: any[]): Promise { + const evaluatedArgs = await self.evaluateCfnExpression(args); + return evaluatedArgs[index]; + } + + async 'Ref'(logicalId: string): Promise { + 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 { + // 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 { + 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('.')); + } + }); + } + } + + if (cfnExpression == null) { + return cfnExpression; + } + + if (Array.isArray(cfnExpression)) { + 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; + } + + 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 { + // first, check to see if the Ref is a Parameter who's value we have + const parameterTarget = this.context[logicalId]; + if (parameterTarget) { + return parameterTarget; + } + // 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 { + const stackResources = await this.stackResources.listStackResources(); + const foundResource = stackResources.find(sr => sr.LogicalResourceId === logicalId); + if (!foundResource) { + return undefined; + } + // now, we need to format the appropriate identifier depending on the resource type, + // and the requested attribute name + return this.formatResourceAttribute(foundResource, attribute); + } + + private formatResourceAttribute(resource: AWS.CloudFormation.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: AWS.CloudFormation.StackResourceSummary): string { + return resource.ResourceType.split('::')[1].toLowerCase(); + } + + private getResourceTypeArnPartOfResource(resource: AWS.CloudFormation.StackResourceSummary): string { + return resource.ResourceType.split('::')[2].toLowerCase(); + } +} + +interface ArnParts { + readonly partition: string; + readonly service: string; + readonly region: string; + readonly account: string; + readonly resourceType: string; + readonly resourceName: string; +} + +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 }, +}; + +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}`; +} + +interface Intrinsic { + readonly name: string; + readonly args: any; +} + +async function asyncGlobalReplace(str: string, regex: RegExp, cb: (x: string) => Promise): Promise { + if (!regex.global) { throw new Error('Regex must be created with /g flag'); } + + const ret = new Array(); + 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.substr(start)); + + return ret.join(''); +} diff --git a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts index 73b4c529188c5..c110a66b6c9db 100644 --- a/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts +++ b/packages/aws-cdk/lib/api/hotswap/lambda-functions.ts @@ -1,6 +1,7 @@ import * as cfn_diff from '@aws-cdk/cloudformation-diff'; import { ISDK } from '../aws-auth'; -import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation, ListStackResources, stringifyPotentialCfnExpression } from './common'; +import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, HotswapOperation } from './common'; +import { CfnEvaluationException, EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template'; /** * Returns `false` if the change cannot be short-circuited, @@ -8,10 +9,10 @@ import { assetMetadataChanged, ChangeHotswapImpact, ChangeHotswapResult, Hotswap * (like a change to CDKMetadata), * or a LambdaFunctionResource if the change can be short-circuited. */ -export function isHotswappableLambdaFunctionChange( - logicalId: string, change: cfn_diff.ResourceDifference, assetParamsWithEnv: { [key: string]: string }, -): ChangeHotswapResult { - const lambdaCodeChange = isLambdaFunctionCodeOnlyChange(change, assetParamsWithEnv); +export async function isHotswappableLambdaFunctionChange( + logicalId: string, change: cfn_diff.ResourceDifference, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + const lambdaCodeChange = await isLambdaFunctionCodeOnlyChange(change, evaluateCfnTemplate); if (typeof lambdaCodeChange === 'string') { return lambdaCodeChange; } else { @@ -23,23 +24,13 @@ export function isHotswappableLambdaFunctionChange( return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; } - let functionPhysicalName: string | undefined; - try { - functionPhysicalName = stringifyPotentialCfnExpression(change.newValue?.Properties?.FunctionName, assetParamsWithEnv); - } catch (e) { - // It's possible we can't evaluate the function's name - - // for example, it can use a Ref to a different resource, - // which we wouldn't have in `assetParamsWithEnv`. - // That's fine though - ignore any errors, - // and treat this case the same way as if the name wasn't provided at all, - // which means it will be looked up using the listStackResources() call - // by the later phase (which actually does the Lambda function update) - functionPhysicalName = undefined; + const functionName = await establishFunctionPhysicalName(logicalId, change, evaluateCfnTemplate); + if (!functionName) { + return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; } return new LambdaFunctionHotswapOperation({ - logicalId, - physicalName: functionPhysicalName, + physicalName: functionName, code: lambdaCodeChange, }); } @@ -54,9 +45,9 @@ export function isHotswappableLambdaFunctionChange( * or a LambdaFunctionCode if the change is to a AWS::Lambda::Function, * and only affects its Code property. */ -function isLambdaFunctionCodeOnlyChange( - change: cfn_diff.ResourceDifference, assetParamsWithEnv: { [key: string]: string }, -): LambdaFunctionCode | ChangeHotswapImpact { +async function isLambdaFunctionCodeOnlyChange( + change: cfn_diff.ResourceDifference, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { if (!change.newValue) { return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; } @@ -99,11 +90,11 @@ function isLambdaFunctionCodeOnlyChange( switch (newPropName) { case 'S3Bucket': foundCodeDifference = true; - s3Bucket = stringifyPotentialCfnExpression(updatedProp.newValue[newPropName], assetParamsWithEnv); + s3Bucket = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]); break; case 'S3Key': foundCodeDifference = true; - s3Key = stringifyPotentialCfnExpression(updatedProp.newValue[newPropName], assetParamsWithEnv); + s3Key = await evaluateCfnTemplate.evaluateCfnExpression(updatedProp.newValue[newPropName]); break; default: return ChangeHotswapImpact.REQUIRES_FULL_DEPLOYMENT; @@ -125,8 +116,7 @@ interface LambdaFunctionCode { } interface LambdaFunctionResource { - readonly logicalId: string; - readonly physicalName?: string; + readonly physicalName: string; readonly code: LambdaFunctionCode; } @@ -134,26 +124,29 @@ class LambdaFunctionHotswapOperation implements HotswapOperation { constructor(private readonly lambdaFunctionResource: LambdaFunctionResource) { } - public async apply(sdk: ISDK, stackResources: ListStackResources): Promise { - let functionPhysicalName: string; - if (this.lambdaFunctionResource.physicalName) { - functionPhysicalName = this.lambdaFunctionResource.physicalName; - } else { - const stackResourceList = await stackResources.listStackResources(); - const foundFunctionName = stackResourceList - .find(resSummary => resSummary.LogicalResourceId === this.lambdaFunctionResource.logicalId) - ?.PhysicalResourceId; - if (!foundFunctionName) { - // if we couldn't find the function in the current stack, we can't update it - return; - } - functionPhysicalName = foundFunctionName; - } - + public async apply(sdk: ISDK): Promise { return sdk.lambda().updateFunctionCode({ - FunctionName: functionPhysicalName, + FunctionName: this.lambdaFunctionResource.physicalName, S3Bucket: this.lambdaFunctionResource.code.s3Bucket, S3Key: this.lambdaFunctionResource.code.s3Key, }).promise(); } } + +async function establishFunctionPhysicalName( + logicalId: string, change: cfn_diff.ResourceDifference, evaluateCfnTemplate: EvaluateCloudFormationTemplate, +): Promise { + const functionNameInCfnTemplate = change.newValue?.Properties?.FunctionName; + if (functionNameInCfnTemplate != null) { + try { + return await evaluateCfnTemplate.evaluateCfnExpression(functionNameInCfnTemplate); + } catch (e) { + // If we can't evaluate the function's name CloudFormation expression, + // just look it up in the currently deployed Stack + if (!(e instanceof CfnEvaluationException)) { + throw e; + } + } + } + return evaluateCfnTemplate.findPhysicalNameFor(logicalId); +} diff --git a/packages/aws-cdk/lib/api/util/cloudformation/evaluate-cfn.ts b/packages/aws-cdk/lib/api/util/cloudformation/evaluate-cfn.ts deleted file mode 100644 index bdc395df83814..0000000000000 --- a/packages/aws-cdk/lib/api/util/cloudformation/evaluate-cfn.ts +++ /dev/null @@ -1,89 +0,0 @@ -export function evaluateCfn(object: any, context: { [key: string]: string }): any { - const intrinsicFns: any = { - 'Fn::Join'(separator: string, args: string[]): string { - return evaluate(args).map(evaluate).join(separator); - }, - - 'Fn::Split'(separator: string, args: string): string { - return evaluate(args).split(separator); - }, - - 'Fn::Select'(index: number, args: string[]): string { - return evaluate(args).map(evaluate)[index]; - }, - - 'Ref'(logicalId: string): string { - if (logicalId in context) { - return context[logicalId]; - } else { - throw new Error(`Reference target '${logicalId}' was not found`); - } - }, - - 'Fn::Sub'(template: string, explicitPlaceholders?: { [variable: string]: string }): string { - const placeholders = explicitPlaceholders - ? { ...context, ...evaluate(explicitPlaceholders) } - : context; - - return template.replace(/\${([^}]*)}/g, (_: string, key: string) => { - if (key in placeholders) { - return placeholders[key]; - } else { - throw new Error(`Fn::Sub target '${key}' was not found`); - } - }); - }, - }; - - return evaluate(object); - - function evaluate(obj: any): any { - if (Array.isArray(obj)) { - return obj.map(evaluate); - } - - if (typeof obj === 'object') { - const intrinsic = parseIntrinsic(obj); - if (intrinsic) { - return evaluateIntrinsic(intrinsic); - } - - const ret: { [key: string]: any } = {}; - for (const key of Object.keys(obj)) { - ret[key] = evaluate(obj[key]); - } - return ret; - } - - return obj; - } - - function evaluateIntrinsic(intrinsic: Intrinsic) { - if (!(intrinsic.name in intrinsicFns)) { - throw new Error(`Intrinsic ${intrinsic.name} not supported here`); - } - - const argsAsArray = Array.isArray(intrinsic.args) ? intrinsic.args : [intrinsic.args]; - - return intrinsicFns[intrinsic.name].apply(intrinsicFns, argsAsArray); - } -} - -interface Intrinsic { - readonly name: string; - readonly args: any; -} - -function parseIntrinsic(x: any): Intrinsic | undefined { - if (typeof x !== 'object' || x === null) { - return 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; -} diff --git a/packages/aws-cdk/test/api/hotswap-deployments.test.ts b/packages/aws-cdk/test/api/hotswap-deployments.test.ts index a241e7c707d5c..5bb6ff8b7b466 100644 --- a/packages/aws-cdk/test/api/hotswap-deployments.test.ts +++ b/packages/aws-cdk/test/api/hotswap-deployments.test.ts @@ -1,5 +1,5 @@ import * as cxapi from '@aws-cdk/cx-api'; -import { Lambda } from 'aws-sdk'; +import { CloudFormation, Lambda } from 'aws-sdk'; import { tryHotswapDeployment } from '../../lib/api/hotswap-deployments'; import { testStack, TestStackArtifact } from '../util'; import { MockSdkProvider } from '../util/mock-sdk'; @@ -11,6 +11,7 @@ const STACK_ID = 'stackId'; let mockSdkProvider: MockSdkProvider; let mockUpdateLambdaCode: (params: Lambda.Types.UpdateFunctionCodeRequest) => Lambda.Types.FunctionConfiguration; let currentCfnStack: FakeCloudformationStack; +const currentCfnStackResources: CloudFormation.StackResourceSummary[] = []; beforeEach(() => { jest.resetAllMocks(); @@ -19,6 +20,18 @@ beforeEach(() => { mockSdkProvider.stubLambda({ updateFunctionCode: mockUpdateLambdaCode, }); + // clear the array + currentCfnStackResources.splice(0); + mockSdkProvider.stubCloudFormation({ + listStackResources: ({ StackName: stackName }) => { + if (stackName !== STACK_NAME) { + throw new Error(`Expected Stack name in listStackResources() call to be: '${STACK_NAME}', but received: ${stackName}'`); + } + return { + StackResourceSummaries: currentCfnStackResources, + }; + }, + }); currentCfnStack = new FakeCloudformationStack({ stackName: STACK_NAME, stackId: STACK_ID, @@ -105,9 +118,254 @@ test('calls the updateLambdaCode() API when it receives only a code difference i }); }); +test("correctly evaluates the function's name when it references a different resource from the template", async () => { + // GIVEN + currentCfnStack.setTemplate({ + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'current-bucket', + S3Key: 'current-key', + }, + FunctionName: { + 'Fn::Join': ['-', [ + 'lambda', + { Ref: 'Bucket' }, + 'function', + ]], + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + currentCfnStackResources.push(stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'mybucket')); + const cdkStackArtifact = cdkStackArtifactOf({ + template: { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: 'current-bucket', + S3Key: 'new-key', + }, + FunctionName: { + 'Fn::Join': ['-', [ + 'lambda', + { Ref: 'Bucket' }, + 'function', + ]], + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await tryHotswapDeployment(mockSdkProvider, {}, currentCfnStack, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateLambdaCode).toHaveBeenCalledWith({ + FunctionName: 'lambda-mybucket-function', + S3Bucket: 'current-bucket', + S3Key: 'new-key', + }); +}); + +test("correctly falls back to taking the function's name from the current stack if it can't evaluate it in the template", async () => { + // GIVEN + currentCfnStack.setTemplate({ + Parameters: { + Param1: { Type: 'String' }, + AssetBucketParam: { Type: 'String' }, + }, + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { Ref: 'AssetBucketParam' }, + S3Key: 'current-key', + }, + FunctionName: { Ref: 'Param1' }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + currentCfnStackResources.push(stackSummaryOf('Func', 'AWS::Lambda::Function', 'my-function')); + const cdkStackArtifact = cdkStackArtifactOf({ + template: { + Parameters: { + Param1: { Type: 'String' }, + AssetBucketParam: { Type: 'String' }, + }, + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { Ref: 'AssetBucketParam' }, + S3Key: 'new-key', + }, + FunctionName: { Ref: 'Param1' }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // WHEN + const deployStackResult = await tryHotswapDeployment(mockSdkProvider, { + AssetBucketParam: 'asset-bucket', + }, currentCfnStack, cdkStackArtifact); + + // THEN + expect(deployStackResult).not.toBeUndefined(); + expect(mockUpdateLambdaCode).toHaveBeenCalledWith({ + FunctionName: 'my-function', + S3Bucket: 'asset-bucket', + S3Key: 'new-key', + }); +}); + +test("will not perform a hotswap deployment if it cannot find a Ref target (outside the function's name)", async () => { + // GIVEN + currentCfnStack.setTemplate({ + Parameters: { + Param1: { Type: 'String' }, + }, + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { 'Fn::Sub': '${Param1}' }, + S3Key: 'current-key', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + currentCfnStackResources.push(stackSummaryOf('Func', 'AWS::Lambda::Function', 'my-func')); + const cdkStackArtifact = cdkStackArtifactOf({ + template: { + Parameters: { + Param1: { Type: 'String' }, + }, + Resources: { + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { 'Fn::Sub': '${Param1}' }, + S3Key: 'new-key', + }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // THEN + await expect(() => + tryHotswapDeployment(mockSdkProvider, {}, currentCfnStack, cdkStackArtifact), + ).rejects.toThrow(/Parameter or resource 'Param1' could not be found for evaluation/); +}); + +test("will not perform a hotswap deployment if it doesn't know how to handle a specific attribute (outside the function's name)", async () => { + // GIVEN + currentCfnStack.setTemplate({ + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, + S3Key: 'current-key', + }, + }, + Metadata: { + 'aws:asset:path': 'old-path', + }, + }, + }, + }); + currentCfnStackResources.push( + stackSummaryOf('Func', 'AWS::Lambda::Function', 'my-func'), + stackSummaryOf('Bucket', 'AWS::S3::Bucket', 'my-bucket'), + ); + const cdkStackArtifact = cdkStackArtifactOf({ + template: { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + }, + Func: { + Type: 'AWS::Lambda::Function', + Properties: { + Code: { + S3Bucket: { 'Fn::GetAtt': ['Bucket', 'UnknownAttribute'] }, + S3Key: 'new-key', + }, + }, + Metadata: { + 'aws:asset:path': 'new-path', + }, + }, + }, + }, + }); + + // THEN + await expect(() => + tryHotswapDeployment(mockSdkProvider, {}, currentCfnStack, cdkStackArtifact), + ).rejects.toThrow("We don't support the 'UnknownAttribute' attribute of the 'AWS::S3::Bucket' resource. This is a CDK limitation. Please report it at https://github.com/aws/aws-cdk/issues/new/choose"); +}); + function cdkStackArtifactOf(testStackArtifact: Partial = {}): cxapi.CloudFormationStackArtifact { return testStack({ stackName: STACK_NAME, ...testStackArtifact, }); } + +function stackSummaryOf(logicalId: string, resourceType: string, physicalResourceId: string): CloudFormation.StackResourceSummary { + return { + LogicalResourceId: logicalId, + PhysicalResourceId: physicalResourceId, + ResourceType: resourceType, + ResourceStatus: 'CREATE_COMPLETE', + LastUpdatedTimestamp: new Date(), + }; +}