Skip to content

Commit

Permalink
fix(cdk): Resolve cross stack and default parameters for hotswaps (#2…
Browse files Browse the repository at this point in the history
…7195)

This PR solves two problems when doing hotswap deployments with nested stacks.

1. Adding capabilities to evaluate CFN parameters of `AWS::CloudFormation::Stack` type (i.e. a nested stack). In this PR, we are only resolving `Outputs` section of `AWS::CloudFormation::Stack` and it can be expanded to other fields in the future. See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-cloudformation.html#w2ab1c17c23c19b5 for CFN documentation around using Outputs ref parameters
2. If a template has parameters with default values and they are not provided (a value) by the parent stack when invoking, then resolve these parameters using the default values in the template.

Partially helps #23533 and #25418

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
Amplifiyer committed Sep 28, 2023
1 parent 7df11f4 commit 3507141
Show file tree
Hide file tree
Showing 6 changed files with 315 additions and 18 deletions.
65 changes: 58 additions & 7 deletions packages/aws-cdk/lib/api/evaluate-cloudformation-template.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as AWS from 'aws-sdk';
import { ISDK } from './aws-auth';
import { NestedStackNames } from './nested-stack-helpers';

export interface ListStackResources {
listStackResources(): Promise<AWS.CloudFormation.StackResourceSummary[]>;
Expand Down Expand Up @@ -42,27 +43,33 @@ export interface ResourceDefinition {
}

export interface EvaluateCloudFormationTemplateProps {
readonly stackName: string;
readonly template: Template;
readonly parameters: { [parameterName: string]: string };
readonly account: string;
readonly region: string;
readonly partition: string;
readonly urlSuffix: (region: string) => string;
readonly listStackResources: ListStackResources;
readonly sdk: ISDK;
readonly nestedStackNames?: { [nestedStackLogicalId: string]: NestedStackNames };
}

export class EvaluateCloudFormationTemplate {
private readonly stackResources: ListStackResources;
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 urlSuffix: (region: string) => string;
private readonly sdk: ISDK;
private readonly nestedStackNames: { [nestedStackLogicalId: string]: NestedStackNames };
private readonly stackResources: LazyListStackResources;

private cachedUrlSuffix: string | undefined;

constructor(props: EvaluateCloudFormationTemplateProps) {
this.stackResources = props.listStackResources;
this.stackName = props.stackName;
this.template = props.template;
this.context = {
'AWS::AccountId': props.account,
Expand All @@ -74,22 +81,34 @@ export class EvaluateCloudFormationTemplate {
this.region = props.region;
this.partition = props.partition;
this.urlSuffix = props.urlSuffix;
this.sdk = props.sdk;

// We need names of nested stack so we can evaluate cross stack references
this.nestedStackNames = props.nestedStackNames ?? {};

// 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);
}

// clones current EvaluateCloudFormationTemplate object, but updates the stack name
public createNestedEvaluateCloudFormationTemplate(
listNestedStackResources: ListStackResources,
public async createNestedEvaluateCloudFormationTemplate(
stackName: string,
nestedTemplate: Template,
nestedStackParameters: { [parameterName: string]: any },
) {
const evaluatedParams = await this.evaluateCfnExpression(nestedStackParameters);
return new EvaluateCloudFormationTemplate({
stackName,
template: nestedTemplate,
parameters: nestedStackParameters,
parameters: evaluatedParams,
account: this.account,
region: this.region,
partition: this.partition,
urlSuffix: this.urlSuffix,
listStackResources: listNestedStackResources,
sdk: this.sdk,
nestedStackNames: this.nestedStackNames,
});
}

Expand Down Expand Up @@ -262,20 +281,52 @@ export class EvaluateCloudFormationTemplate {
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.')) {
// need to resolve attributes from another stack's Output section
const dependantStackName = this.nestedStackNames[logicalId]?.nestedStackPhysicalName;
if (!dependantStackName) {
//this is a newly created nested stack and cannot be hotswapped
return undefined;
}
const dependantStackTemplate = this.template.Resources[logicalId];
const evaluateCfnTemplate = await this.createNestedEvaluateCloudFormationTemplate(
dependantStackName,
dependantStackTemplate?.Properties?.NestedTemplate,
dependantStackTemplate.newValue?.Properties?.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);
Expand Down
20 changes: 10 additions & 10 deletions packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as cxapi from '@aws-cdk/cx-api';
import * as chalk from 'chalk';
import { ISDK, Mode, SdkProvider } from './aws-auth';
import { DeployStackResult } from './deploy-stack';
import { EvaluateCloudFormationTemplate, LazyListStackResources } from './evaluate-cloudformation-template';
import { EvaluateCloudFormationTemplate } from './evaluate-cloudformation-template';
import { isHotswappableAppSyncChange } from './hotswap/appsync-mapping-templates';
import { isHotswappableCodeBuildProjectChange } from './hotswap/code-build-projects';
import { ICON, ChangeHotswapResult, HotswapMode, HotswappableChange, NonHotswappableChange, HotswappableChangeCandidate, ClassifiedResourceChanges, reportNonHotswappableChange } from './hotswap/common';
Expand All @@ -24,6 +24,7 @@ const RESOURCE_DETECTORS: { [key:string]: HotswapDetector } = {
'AWS::Lambda::Function': isHotswappableLambdaFunctionChange,
'AWS::Lambda::Version': isHotswappableLambdaFunctionChange,
'AWS::Lambda::Alias': isHotswappableLambdaFunctionChange,

// AppSync
'AWS::AppSync::Resolver': isHotswappableAppSyncChange,
'AWS::AppSync::FunctionConfiguration': isHotswappableAppSyncChange,
Expand Down Expand Up @@ -54,21 +55,21 @@ export async function tryHotswapDeployment(
// 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)).sdk;
// 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 currentTemplate = await loadCurrentTemplateWithNestedStacks(stackArtifact, sdk);

const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
stackName: stackArtifact.stackName,
template: stackArtifact.template,
parameters: assetParams,
account: resolvedEnv.account,
region: resolvedEnv.region,
partition: (await sdk.currentAccount()).partition,
urlSuffix: (region) => sdk.getEndpointSuffix(region),
listStackResources,
sdk,
nestedStackNames: currentTemplate.nestedStackNames,
});

const currentTemplate = await loadCurrentTemplateWithNestedStacks(stackArtifact, sdk);
const stackChanges = cfn_diff.diffTemplate(currentTemplate.deployedTemplate, stackArtifact.template);
const { hotswappableChanges, nonHotswappableChanges } = await classifyResourceChanges(
stackChanges, evaluateCfnTemplate, sdk, currentTemplate.nestedStackNames,
Expand Down Expand Up @@ -231,9 +232,8 @@ async function findNestedHotswappableChanges(
};
}

const nestedStackParameters = await evaluateCfnTemplate.evaluateCfnExpression(change.newValue?.Properties?.Parameters);
const evaluateNestedCfnTemplate = evaluateCfnTemplate.createNestedEvaluateCloudFormationTemplate(
new LazyListStackResources(sdk, nestedStackName), change.newValue?.Properties?.NestedTemplate, nestedStackParameters,
const evaluateNestedCfnTemplate = await evaluateCfnTemplate.createNestedEvaluateCloudFormationTemplate(
nestedStackName, change.newValue?.Properties?.NestedTemplate, change.newValue?.Properties?.Parameters,
);

const nestedDiff = cfn_diff.diffTemplate(
Expand Down
3 changes: 2 additions & 1 deletion packages/aws-cdk/lib/api/logs/find-cloudwatch-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,14 @@ export async function findCloudWatchLogGroups(

const listStackResources = new LazyListStackResources(sdk, stackArtifact.stackName);
const evaluateCfnTemplate = new EvaluateCloudFormationTemplate({
stackName: stackArtifact.stackName,
template: stackArtifact.template,
parameters: {},
account: resolvedEnv.account,
region: resolvedEnv.region,
partition: (await sdk.currentAccount()).partition,
urlSuffix: (region) => sdk.getEndpointSuffix(region),
listStackResources,
sdk,
});

const stackResources = await listStackResources.listStackResources();
Expand Down
Loading

0 comments on commit 3507141

Please sign in to comment.