Skip to content

(diff): cdk diff for nested stacks compares against root stack template instead of nested stack template #1228

@git-ogawa

Description

@git-ogawa

Describe the bug

When computing the mangled diff for nested stacks, formatStackDiffHelper references this.oldTemplate (the root stack's deployed template) instead of the oldTemplate parameter (the nested stack's deployed template).
This causes cdk diff to produce incorrect output for nested stacks under certain conditions.

Regression Issue

  • Select this option if this issue appears to be a regression.

Last Known Working CDK Version

2.1006.0

Expected Behavior

cdk diff for a nested stack should compute the difference between the nested stack's deployed template and its generated template.

Current Behavior

During the cdk diff calculation, formatStackDiffHelper calculates the mangledDiff from the deployed template and the new template to compute the difference:

https://github.com/aws/aws-cdk-cli/blob/main/packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts#L268-L276

// detect and filter out mangled characters from the diff
if (diff.differenceCount && !options.strict) {
  const mangledNewTemplate = JSON.parse(mangleLikeCloudFormation(JSON.stringify(this.newTemplate.template)));
  const mangledDiff = fullDiff(this.oldTemplate, mangledNewTemplate, this.changeSet);
  filteredChangesCount = Math.max(0, diff.differenceCount - mangledDiff.differenceCount);
  if (filteredChangesCount > 0) {
    diff = mangledDiff;
  }
}

For root stack

For the root stack, it correctly evaluates the diff because this.oldTemplate is the root stack's deployed template.

Retrieving the root template.

  const currentTemplate = templateWithNestedStacks.deployedRootTemplate;
    ...
  templateInfos.push({
    oldTemplate: currentTemplate,

  });

Creating the DiffFormatter instance

const formatter = new DiffFormatter({ templateInfo });

The constructor saves the root deployed template to this.oldTemplate

constructor(props: DiffFormatterProps) {

Root stack diff

public formatStackDiff(options: FormatStackDiffOptions = {}): FormatStackDiffOutput {
  return this.formatStackDiffHelper(
    this.oldTemplate,  // ← Passes the root deployed template as a parameter
    this.stackName,
    this.nestedStacks,
    options,
    this.mappings,
  );
}

For nested stacks

However, for nested stacks, formatStackDiffHelper is called recursively

https://github.com/aws/aws-cdk-cli/blob/main/packages/%40aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts#L309-L321

   const nestedStack = nestedStackTemplates[nestedStackLogicalId];

      (this.newTemplate as any)._template = nestedStack.generatedTemplate;
      const nextDiff = this.formatStackDiffHelper(
        nestedStack.deployedTemplate,
        nestedStack.physicalName ?? nestedStackLogicalId,
        nestedStack.nestedStackTemplates,
        options,
        this.mappings,
      );
      numStacksWithChanges += nextDiff.numStacksWithChanges;
      formattedDiff += nextDiff.formattedDiff;
    }

The oldTemplate parameter correctly receives the nested stack's deployed template. But the class property this.oldTemplate still retains the root stack's template set in the constructor.
Since mangledDiff is calculated using this.oldTemplate, it incorrectly uses the root stack's deployed template instead of the nested stack's.

If filteredChangesCount > 0, the assignment diff = mangledDiff overwrites the correct diff (nested deployed vs. nested generated template) with a cross-comparison between the root parent template and the nested template.
As a result, it outputs a meaningless diff between completely different templates, rather than the actual changes in the nested stack.

If filteredChangesCount <= 0, it is not overwritten, so there is no impact.

Additionally, when the --strict option is passed to cdk diff, the mangled filter process mentioned above is skipped, so it is expected to return the correct diff.

Reproduction Steps

This issue occurs when the condition filteredChangesCount > 0 is met during the cdk diff calculation.
I have confirmed that the issue can be reproduced using the code below:

  1. Create a CDK project using npx cdk init app --language typescript with the latest CDK version.
  2. Generate about 20 CfnOutputs to increase the template differences.
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

class StackA extends cdk.NestedStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    new cdk.aws_s3.Bucket(this, 'MyBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    for (let i = 0; i < 20; i++) {
      new cdk.CfnOutput(this, `Out${i}`, { value: `value-${i}` });
    }
  }
}

export class TestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    new StackA(this, 'A');
  }
}
  1. npx cdk deploy
  2. Remove the for loop (all 20 outputs).
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';

class StackA extends cdk.NestedStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    new cdk.aws_s3.Bucket(this, 'MyBucket', {
      removalPolicy: cdk.RemovalPolicy.DESTROY,
    });
    // for (let i = 0; i < 20; i++) {
    //   new cdk.CfnOutput(this, `Out${i}`, { value: `value-${i}` });
    // }
  }
}

export class TestStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);
    new StackA(this, 'A');
  }
}
  1. Run npx cdk diff — shows incorrect diff. (This output actually shows the difference between the deployed root stack template and the nested stack's generated template).
$ npx cdk diff
Stack TestStack
Resources
[~] AWS::CloudFormation::Stack A.NestedStack/A.NestedStackResource ANestedStackANestedStackResource5CBB2250
 └─ [~] TemplateURL
     └─ [~] .Fn::Join:
         └─ @@ -13,6 +13,6 @@
            [ ]     {
            [ ]       "Fn::Sub": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}"
            [ ]     },
            [-]     "/bab1cb19ccfd92073d1509d7ea04b79a4519332cdb70d63effb0b0c1448fd0e6.json"
            [+]     "/83504affc354b5989d9c1e29a92ce3a0b86741cc3b8574f255b504cc9b1ee955.json"
            [ ]   ]
            [ ] ]

Stack TestStack-ANestedStackANestedStackResource5CBB2250-I07ST41MA3BF
Parameters
[-] Parameter BootstrapVersion BootstrapVersion: {"Type":"AWS::SSM::Parameter::Value<String>","Default":"/cdk-bootstrap/hnb659fds/version","Description":"Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip]"}

Resources
[-] AWS::CloudFormation::Stack A.NestedStack/A.NestedStackResource ANestedStackANestedStackResource5CBB2250 destroy
[+] AWS::S3::Bucket A/MyBucket MyBucketF68F3FF0

Omitted 16 changes because they are likely mangled non-ASCII characters. Use --strict to print them.
  1. Run npx cdk diff --strict — shows correct diff.

Possible Solution

The calculation of mangledDiff in formatStackDiff was refactored in PR #283 (f9f6d1d).
In this refactor, the oldTemplate was incorrectly replaced with this.oldTemplate.

Before the refactor in #278, oldTemplate was used in this part.

const mangledDiff = fullDiff(oldTemplate, mangledNewTemplate, changeSet);

After refactor (#283, bug):

const mangledDiff = fullDiff(this.oldTemplate, mangledNewTemplate, options.changeSet);

This remains unfixed in the current main branch:

const mangledDiff = fullDiff(this.oldTemplate, mangledNewTemplate, this.changeSet);

so the fix would likely be replacing this.oldTemplate with the oldTemplate parameter in formatStackDiffHelper:

Additional Information/Context

This bug appears to have been introduced in CDK CLI version 2.1007.0, after PR #283 was merged.
Versions 2.1006.0 and earlier return the correct cdk diff results.

CDK CLI Version

2.1111.0

Framework Version

No response

Node.js Version

v22.12.0

OS

AmazonLinux 2023, Ubuntu

Language

TypeScript

Language Version

No response

Other information

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions