Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): cdk diff works for Nested Stacks #18207

Merged
merged 16 commits into from
Feb 3, 2022
158 changes: 142 additions & 16 deletions packages/aws-cdk/lib/api/cloudformation-deployments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as path from 'path';
import * as cxapi from '@aws-cdk/cx-api';
import { AssetManifest } from 'cdk-assets';
import * as fs from 'fs-extra';
import { LazyListStackResources, ListStackResources } from '../api/evaluate-cloudformation-template';
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
import { Tag } from '../cdk-toolkit';
import { debug, warning } from '../logging';
import { publishAssets } from '../util/asset-publishing';
Expand Down Expand Up @@ -293,25 +296,21 @@ export class CloudFormationDeployments {
this.sdkProvider = props.sdkProvider;
}

public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
debug(`Reading existing template for stack ${stackArtifact.displayName}.`);
let stackSdk: ISDK | undefined = undefined;
// try to assume the lookup role and fallback to the deploy role
try {
const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact);
if (result.didAssumeRole) {
stackSdk = result.sdk;
}
} catch { }
public async readCurrentTemplateWithNestedStacks(rootStackArtifact: cxapi.CloudFormationStackArtifact): Promise<Template> {
const sdk = await this.prepareSdkWithLookupOrDeployRole(rootStackArtifact);
const deployedTemplate = await this.readCurrentTemplate(rootStackArtifact, sdk);
await this.replaceNestedStacksInRootStack(rootStackArtifact, deployedTemplate, sdk);
comcalvi marked this conversation as resolved.
Show resolved Hide resolved

comcalvi marked this conversation as resolved.
Show resolved Hide resolved
if (!stackSdk) {
stackSdk = (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
}
return deployedTemplate;
}

const cfn = stackSdk.cloudFormation();
public async readCurrentTemplate(stackArtifact: cxapi.CloudFormationStackArtifact, sdk?: ISDK): Promise<Template> {
debug(`Reading existing template for stack ${stackArtifact.displayName}.`);
if (!sdk) {
sdk = await this.prepareSdkWithLookupOrDeployRole(stackArtifact);
}

comcalvi marked this conversation as resolved.
Show resolved Hide resolved
const stack = await CloudFormationStack.lookup(cfn, stackArtifact.stackName);
return stack.template();
return this.readCurrentStackTemplate(stackArtifact.stackName, sdk);
}

public async deployStack(options: DeployStackOptions): Promise<DeployStackResult> {
Expand Down Expand Up @@ -372,6 +371,123 @@ export class CloudFormationDeployments {
return stack.exists;
}

private async prepareSdkWithLookupOrDeployRole(stackArtifact: cxapi.CloudFormationStackArtifact): Promise<ISDK> {
// try to assume the lookup role
try {
const result = await prepareSdkWithLookupRoleFor(this.sdkProvider, stackArtifact);
if (result.didAssumeRole) {
return result.sdk;
}
} catch { }

comcalvi marked this conversation as resolved.
Show resolved Hide resolved
// fall back to the deploy role
return (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
}

private async readCurrentStackTemplate(stackName: string, stackSdk: ISDK) : Promise<Template> {
// if `stackName !== stackArtifact.stackName`, then `stackArtifact` is an ancestor to a nested stack with name `stackName`.
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
const cfn = stackSdk.cloudFormation();
const stack = await CloudFormationStack.lookup(cfn, stackName);
return stack.template();
}

private async replaceNestedStacksInRootStack(
rootStackArtifact: cxapi.CloudFormationStackArtifact, deployedTemplate: any, sdk: ISDK,
): Promise<void> {
await this.replaceNestedStacksInParentTemplate(
rootStackArtifact, rootStackArtifact.template, deployedTemplate, rootStackArtifact.stackName, sdk,
);
}
comcalvi marked this conversation as resolved.
Show resolved Hide resolved

private async replaceNestedStacksInParentTemplate(
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
rootStackArtifact: cxapi.CloudFormationStackArtifact,
generatedParentTemplate: any,
deployedParentTemplate: any,
parentStackName: string | undefined,
sdk: ISDK,
): Promise<void> {
const listStackResources = parentStackName ? new LazyListStackResources(sdk, parentStackName) : undefined;
const parentStackResources = Object.entries(generatedParentTemplate.Resources ?? {});
for (const [nestedStackLogicalId, nestedStackResource] of parentStackResources) {
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
if (!this.isCdkManagedNestedStack(nestedStackResource)) {
continue;
}

const assetPath = nestedStackResource.Metadata['aws:asset:path'];
const nestedStackTemplates = await this.getNestedStackTemplates(
rootStackArtifact, assetPath, nestedStackLogicalId, parentStackName, listStackResources, sdk,
);

if (!generatedParentTemplate.Resources[nestedStackLogicalId].Properties) {
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
generatedParentTemplate.Resources[nestedStackLogicalId].Properties = {};
}
generatedParentTemplate.Resources[nestedStackLogicalId].Properties.NestedTemplate = nestedStackTemplates.generatedNestedTemplate;
comcalvi marked this conversation as resolved.
Show resolved Hide resolved

if (!deployedParentTemplate.Resources) {
deployedParentTemplate.Resources = {};
}
if (!deployedParentTemplate.Resources[nestedStackLogicalId]) {
deployedParentTemplate.Resources[nestedStackLogicalId] = {};
deployedParentTemplate.Resources[nestedStackLogicalId].Type = 'AWS::CloudFormation::Stack';
}
if (!deployedParentTemplate.Resources[nestedStackLogicalId].Properties) {
deployedParentTemplate.Resources[nestedStackLogicalId].Properties = {};
}
deployedParentTemplate.Resources[nestedStackLogicalId].Properties.NestedTemplate = nestedStackTemplates.deployedNestedTemplate;
comcalvi marked this conversation as resolved.
Show resolved Hide resolved

await this.replaceNestedStacksInParentTemplate(
rootStackArtifact,
generatedParentTemplate.Resources[nestedStackLogicalId].Properties.NestedTemplate,
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
deployedParentTemplate.Resources[nestedStackLogicalId].Properties.NestedTemplate ?? {},
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
nestedStackTemplates.nestedStackName,
sdk,
);
}
}

private async getNestedStackTemplates(
rootStackArtifact: cxapi.CloudFormationStackArtifact, nestedTemplateAssetPath: string, nestedStackLogicalId: string,
parentStackName: string | undefined, listStackResources: ListStackResources | undefined, sdk: ISDK,
): Promise<NestedStackTemplates> {
const nestedTemplatePath = path.join(rootStackArtifact.assembly.directory, nestedTemplateAssetPath);
const nestedStackArn = await this.getNestedStackArn(nestedStackLogicalId, parentStackName, listStackResources);

// CFN generates the nested stack name in the form `ParentStackName-NestedStackLogicalID-SomeHashWeCan'tCompute,
// the arn is of the form: arn:aws:cloudformation:region:123456789012:stack/NestedStackName/AnotherHashWeDon'tNeed
// so we get the ARN and manually extract the name.
const nestedStackName = nestedStackArn?.slice(nestedStackArn.indexOf('/') + 1, nestedStackArn.lastIndexOf('/'));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably want to add a comment here, showing the structure of a nested Stack ARN - otherwise, this code is quite myterious.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, I just realized I don't understand why this code works at all 😛. Why are we skipping the part after the last /? What is that, that we can skip it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the part after the / is a gibberish string (eg this string from the cfn docs: f449b250-b969-11e0-a185-5081d0136786) that is not part of the nested stack name. I assume it's a hash, but I haven't been able to find documentation to support that assumption.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hash of what though? 🙂

In either case, a comment explaining all of this is definitely needed.

comcalvi marked this conversation as resolved.
Show resolved Hide resolved

return {
generatedNestedTemplate: JSON.parse(fs.readFileSync(nestedTemplatePath, 'utf-8')),
deployedNestedTemplate: nestedStackName
? await this.readCurrentStackTemplate(nestedStackName, sdk)
: {},
nestedStackName,
};
}

private async getNestedStackArn(
nestedStackLogicalId: string, parentStackName?: string, listStackResources?: ListStackResources,
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
): Promise<string | undefined> {
try {
const stackResources = await listStackResources?.listStackResources();

comcalvi marked this conversation as resolved.
Show resolved Hide resolved
if (stackResources) {
return stackResources.find(sr => sr.LogicalResourceId === nestedStackLogicalId)?.PhysicalResourceId;
}
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
} catch (e) {
if (e.message !== `Stack with id ${parentStackName} does not exist`) {
throw e;
}
}

return undefined;
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
}

private isCdkManagedNestedStack(stackResource: any): stackResource is NestedStackResource {
return stackResource.Type === 'AWS::CloudFormation::Stack' && stackResource.Metadata && stackResource.Metadata['aws:asset:path'];
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
}
comcalvi marked this conversation as resolved.
Show resolved Hide resolved

/**
* Get the environment necessary for touching the given stack
*
Expand Down Expand Up @@ -453,3 +569,13 @@ export class CloudFormationDeployments {
function isAssetManifestArtifact(art: cxapi.CloudArtifact): art is cxapi.AssetManifestArtifact {
return art instanceof cxapi.AssetManifestArtifact;
}

interface NestedStackTemplates {
readonly generatedNestedTemplate: any,
readonly deployedNestedTemplate: any,
readonly nestedStackName: string | undefined,
comcalvi marked this conversation as resolved.
Show resolved Hide resolved
}

interface NestedStackResource {
readonly Metadata: { 'aws:asset:path': string };
}
1 change: 0 additions & 1 deletion packages/aws-cdk/lib/api/hotswap-deployments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,4 +234,3 @@ async function applyHotswappableChange(sdk: ISDK, hotswapOperation: HotswapOpera
sdk.removeCustomUserAgent(customUserAgent);
}
}

comcalvi marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 2 additions & 2 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as path from 'path';
import { format } from 'util';
import * as cxapi from '@aws-cdk/cx-api';
import * as chokidar from 'chokidar';
import * as chalk from 'chalk';
import * as chokidar from 'chokidar';
import * as fs from 'fs-extra';
import * as promptly from 'promptly';
import { environmentsFromDescriptors, globEnvironmentsFromStacks, looksLikeGlob } from '../lib/api/cxapp/environments';
Expand Down Expand Up @@ -104,7 +104,7 @@ export class CdkToolkit {
// Compare N stacks against deployed templates
for (const stack of stacks.stackArtifacts) {
stream.write(format('Stack %s\n', chalk.bold(stack.displayName)));
const currentTemplate = await this.props.cloudFormation.readCurrentTemplate(stack);
const currentTemplate = await this.props.cloudFormation.readCurrentTemplateWithNestedStacks(stack);
diffs += options.securityOnly
? numberFromBool(printSecurityDiff(currentTemplate, stack, RequireApproval.Broadening))
: printStackDiff(currentTemplate, stack, strict, contextLines, stream);
Expand Down
Loading