Skip to content

Commit

Permalink
feat(cli): cdk diff works for Nested Stacks (#18207)
Browse files Browse the repository at this point in the history
`cdk diff` now compares all template objects (Resources, Outputs, etc) across nested stacks.

Closes #5722.

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
comcalvi authored Feb 3, 2022
1 parent 323281e commit 1337b24
Show file tree
Hide file tree
Showing 10 changed files with 1,159 additions and 116 deletions.
135 changes: 118 additions & 17 deletions packages/aws-cdk/lib/api/cloudformation-deployments.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
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 { Tag } from '../cdk-toolkit';
import { debug, warning } from '../logging';
import { publishAssets } from '../util/asset-publishing';
import { Mode, SdkProvider, ISDK } from './aws-auth';
import { deployStack, DeployStackResult, destroyStack } from './deploy-stack';
import { LazyListStackResources, ListStackResources } from './evaluate-cloudformation-template';
import { ToolkitInfo } from './toolkit-info';
import { CloudFormationStack, Template } from './util/cloudformation';
import { StackActivityProgress } from './util/cloudformation/stack-activity-monitor';
Expand Down Expand Up @@ -293,25 +296,23 @@ 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.addNestedTemplatesToGeneratedAndDeployedStacks(rootStackArtifact, sdk, {
generatedTemplate: rootStackArtifact.template,
deployedTemplate: deployedTemplate,
deployedStackName: rootStackArtifact.stackName,
});
return deployedTemplate;
}

if (!stackSdk) {
stackSdk = (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
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);
}

const cfn = stackSdk.cloudFormation();

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 +373,95 @@ 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 { }
// fall back to the deploy role
return (await this.prepareSdkFor(stackArtifact, undefined, Mode.ForReading)).stackSdk;
}

private async readCurrentStackTemplate(stackName: string, stackSdk: ISDK) : Promise<Template> {
const cfn = stackSdk.cloudFormation();
const stack = await CloudFormationStack.lookup(cfn, stackName);
return stack.template();
}

private async addNestedTemplatesToGeneratedAndDeployedStacks(
rootStackArtifact: cxapi.CloudFormationStackArtifact,
sdk: ISDK,
parentTemplates: StackTemplates,
): Promise<void> {
const listStackResources = parentTemplates.deployedStackName ? new LazyListStackResources(sdk, parentTemplates.deployedStackName) : undefined;
for (const [nestedStackLogicalId, generatedNestedStackResource] of Object.entries(parentTemplates.generatedTemplate.Resources ?? {})) {
if (!this.isCdkManagedNestedStack(generatedNestedStackResource)) {
continue;
}

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

generatedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.generatedTemplate;

const deployedParentTemplate = parentTemplates.deployedTemplate;
deployedParentTemplate.Resources = deployedParentTemplate.Resources ?? {};
const deployedNestedStackResource = deployedParentTemplate.Resources[nestedStackLogicalId] ?? {};
deployedParentTemplate.Resources[nestedStackLogicalId] = deployedNestedStackResource;
deployedNestedStackResource.Type = deployedNestedStackResource.Type ?? 'AWS::CloudFormation::Stack';
deployedNestedStackResource.Properties = deployedNestedStackResource.Properties ?? {};
deployedNestedStackResource.Properties.NestedTemplate = nestedStackTemplates.deployedTemplate;

await this.addNestedTemplatesToGeneratedAndDeployedStacks(
rootStackArtifact,
sdk,
nestedStackTemplates,
);
}
}

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

// 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 nestedStackArn = await this.getNestedStackArn(nestedStackLogicalId, listStackResources);
const deployedStackName = nestedStackArn?.slice(nestedStackArn.indexOf('/') + 1, nestedStackArn.lastIndexOf('/'));

return {
generatedTemplate: JSON.parse(fs.readFileSync(nestedTemplatePath, 'utf-8')),
deployedTemplate: deployedStackName
? await this.readCurrentStackTemplate(deployedStackName, sdk)
: {},
deployedStackName,
};
}

private async getNestedStackArn(
nestedStackLogicalId: string, listStackResources?: ListStackResources,
): Promise<string | undefined> {
try {
const stackResources = await listStackResources?.listStackResources();
return stackResources?.find(sr => sr.LogicalResourceId === nestedStackLogicalId)?.PhysicalResourceId;
} catch (e) {
if (e.message.startsWith('Stack with id ') && e.message.endsWith(' does not exist')) {
return;
}
throw e;
}
}

private isCdkManagedNestedStack(stackResource: any): stackResource is NestedStackResource {
return stackResource.Type === 'AWS::CloudFormation::Stack' && stackResource.Metadata && stackResource.Metadata['aws:asset:path'];
}

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

interface StackTemplates {
readonly generatedTemplate: any;
readonly deployedTemplate: any;
readonly deployedStackName: string | undefined;
}

interface NestedStackResource {
readonly Metadata: { 'aws:asset:path': string };
readonly Properties: any;
}
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);
}
}

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

0 comments on commit 1337b24

Please sign in to comment.