Skip to content
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const cdk = require('aws-cdk-lib');
const sns = require('aws-cdk-lib/aws-sns');
/**
* This stack will be deployed in multiple phases, to achieve a very specific effect
*
* - Deploy Phase: a stack is deployed with an output set to a static string
* - Diff Phase: a reference to a non-existing macro is used for the output value
*
* To exercise this app:
*
* ```
* npx cdk deploy
* env DIFF_PHASE=on npx cdk diff --no-fallback
* # This will surface an error to the user about the missing macro
* ```
*/
class FailingChangesetStack extends cdk.Stack {
constructor(scope, id, props) {
super(scope, id, props);

// Have at least one resource so that we can deploy this
new sns.Topic(this, 'topic', {
removalPolicy: cdk.RemovalPolicy.DESTROY,
});

const outputValue = process.env.DIFF_PHASE ? cdk.Fn.transform('NonExisting', { Param: 'Value' }) : 'static-string'

new cdk.CfnOutput(this, 'MyOutput', {
value: outputValue
})
}
}
const stackPrefix = process.env.STACK_NAME_PREFIX;
const app = new cdk.App();

new FailingChangesetStack(app, `${stackPrefix}-test-failing-changeset`);

app.synth();
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"app": "node app.js",
"versionReporting": false
}
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,26 @@ integTest(
}),
);

integTest(
'cdk diff shows resource metadata changes with --mode=template-only',
withDefaultFixture(async (fixture) => {

// GIVEN - small initial stack with default resource metadata
await fixture.cdkDeploy('metadata');

// WHEN - changing resource metadata value
const diff = await fixture.cdk(['diff --mode=template-only', fixture.fullStackName('metadata')], {
verbose: true,
modEnv: {
INTEG_METADATA_VALUE: 'custom',
},
});

// Assert there are changes
expect(diff).not.toContain('There were no differences');
}),
);

integTest('cdk diff with large changeset and custom toolkit stack name and qualifier does not fail', withoutBootstrap(async (fixture) => {
// Bootstrapping with custom toolkit stack name and qualifier
const qualifier = 'abc1111';
Expand Down Expand Up @@ -2931,3 +2951,40 @@ function actions(requests: CompletedRequest[]): string[] {
.map(x => x.Action as string)
.filter(action => action != null))];
}

integTest(
'cdk diff returns change-set creation errors when using --mode=change-set',
withSpecificFixture('failing-changeset-app', async (fixture) => {
// Should succeed
await fixture.cdkDeploy('test-failing-changeset', {
verbose: false,
});
try {
// Should surface the missing transform error from cloudformation in the output
const diffOutput = await fixture.cdk(['diff', '--mode=change-set', fixture.fullStackName('test-failing-changeset')], {
modEnv: { DIFF_PHASE: 'on' },
allowErrExit: true,
captureStderr: true,
});
expect(diffOutput).toContain('No transform named');

// Should throw
await expect(fixture.cdk(['diff', '--mode=change-set', fixture.fullStackName('test-failing-changeset')], {
modEnv: { DIFF_PHASE: 'on' },
captureStderr: true,
})).rejects.toThrow();

// Should fallback to template-only diff as normal
const diffOutputWithFallback = await fixture.cdk(['diff', fixture.fullStackName('test-failing-changeset')], {
modEnv: { DIFF_PHASE: 'on' },
captureStderr: true,
allowErrExit: true,
});

expect(diffOutputWithFallback).toContain('will base the diff on template differences');

} finally {
await fixture.cdkDestroy('test-failing-changeset', { verbose: false });
}
}),
);
8 changes: 5 additions & 3 deletions packages/aws-cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,11 @@ $ cdk diff --quiet --app='node bin/main.js' MyStackName

Note that the CDK::Metadata resource and the `CheckBootstrapVersion` Rule are excluded from `cdk diff` by default. You can force `cdk diff` to display them by passing the `--strict` flag.

The `change-set` flag will make `diff` create a change set and extract resource replacement data from it. This is a bit slower, but will provide no false positives for resource replacement.
The `--no-change-set` mode will consider any change to a property that requires replacement to be a resource replacement,
even if the change is purely cosmetic (like replacing a resource reference with a hardcoded arn).
The `mode` option selects the approach that will be used to analyze the differences:

- When set to `auto`: CDK will first attempt to create a ChangeSet, should that not be possible due to the stack not yet being created, or any error encountered during the creation of the StackSet, it will automatically fallback to `template-only` mode.
- When set to `change-set`: CDK will create a change set and extract resource replacement data from it. This is a bit slower, but will provide no false positives for resource replacement. Errors in creating a change-set will result in a non-zero exit code. Note that when using this mode against a stack that has not been created will still result in a fallback to `template-only` mode. Also note that this mode will always return an error when used against stacks that contain nested stacks.
- When set to `template-only`: CDK will compare the local template with the template currently applied to the stack. Note that this mode will consider any change to a property that requires replacement to be a resource replacement, even if the change is purely cosmetic (like replacing a resource reference with a hardcoded arn)

### `cdk deploy`

Expand Down
67 changes: 28 additions & 39 deletions packages/aws-cdk/lib/api/util/cloudformation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,9 +353,7 @@ export async function createDiffChangeSet(
// This causes CreateChangeSet to fail with `Template Error: Fn::Equals cannot be partially collapsed`.
for (const resource of Object.values(options.stack.template.Resources ?? {})) {
if ((resource as any).Type === 'AWS::CloudFormation::Stack') {
debug('This stack contains one or more nested stacks, falling back to template-only diff...');

return undefined;
throw new Error('Cannot create change-set diff when using nested stacks');
}
}

Expand Down Expand Up @@ -391,44 +389,35 @@ function templatesFromAssetManifestArtifact(
async function uploadBodyParameterAndCreateChangeSet(
options: PrepareChangeSetOptions,
): Promise<DescribeChangeSetCommandOutput | undefined> {
try {
await uploadStackTemplateAssets(options.stack, options.deployments);
const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack);

const bodyParameter = await makeBodyParameter(
options.stack,
env.resolvedEnvironment,
new AssetManifestBuilder(),
env.resources,
);
const cfn = env.sdk.cloudFormation();
const exists = (await CloudFormationStack.lookup(cfn, options.stack.stackName, false)).exists;

const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn);
options.stream.write(
'Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)\n',
);
await uploadStackTemplateAssets(options.stack, options.deployments);
const env = await options.deployments.envs.accessStackForMutableStackOperations(options.stack);

const bodyParameter = await makeBodyParameter(
options.stack,
env.resolvedEnvironment,
new AssetManifestBuilder(),
env.resources,
);
const cfn = env.sdk.cloudFormation();
const exists = (await CloudFormationStack.lookup(cfn, options.stack.stackName, false)).exists;

return await createChangeSet({
cfn,
changeSetName: 'cdk-diff-change-set',
stack: options.stack,
exists,
uuid: options.uuid,
willExecute: options.willExecute,
bodyParameter,
parameters: options.parameters,
resourcesToImport: options.resourcesToImport,
role: executionRoleArn,
});
} catch (e: any) {
debug(e);
options.stream.write(
'Could not create a change set, will base the diff on template differences (run again with -v to see the reason)\n',
);
const executionRoleArn = await env.replacePlaceholders(options.stack.cloudFormationExecutionRoleArn);
options.stream.write(
'Hold on while we create a read-only change set to get a diff with accurate replacement information (use --no-change-set to use a less accurate but faster template-only diff)\n',
);

return undefined;
}
return createChangeSet({
cfn,
changeSetName: 'cdk-diff-change-set',
stack: options.stack,
exists,
uuid: options.uuid,
willExecute: options.willExecute,
bodyParameter,
parameters: options.parameters,
resourcesToImport: options.resourcesToImport,
role: executionRoleArn,
});
}

/**
Expand Down
46 changes: 35 additions & 11 deletions packages/aws-cdk/lib/cdk-toolkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export class CdkToolkit {

let changeSet = undefined;

if (options.changeSet) {
if (options.changeSet || options.mode === 'auto' || options.mode === 'change-set') {
let stackExists = false;
try {
stackExists = await this.props.deployments.stackExists({
Expand All @@ -218,16 +218,30 @@ export class CdkToolkit {
}

if (stackExists) {
changeSet = await createDiffChangeSet({
stack,
uuid: uuid.v4(),
deployments: this.props.deployments,
willExecute: false,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
resourcesToImport,
stream,
});
try {
changeSet = await createDiffChangeSet({
stack,
uuid: uuid.v4(),
deployments: this.props.deployments,
willExecute: false,
sdkProvider: this.props.sdkProvider,
parameters: Object.assign({}, parameterMap['*'], parameterMap[stack.stackName]),
resourcesToImport,
stream,
});
} catch (e: any) {
if (options.mode === 'auto') {
debug(e);
stream.write(
'Could not create a change set, will base the diff on template differences (run again with -v to see the reason)\n',
);
} else {
stream.write(
`Could not create change set. Reason: ${e.message}\n`,
);
return 1;
}
}
} else {
debug(
`the stack '${stack.stackName}' has not been deployed to CloudFormation or describeStacks call failed, skipping changeset creation.`,
Expand Down Expand Up @@ -1354,9 +1368,19 @@ export interface DiffOptions {
/**
* Whether or not to create, analyze, and subsequently delete a changeset
*
* @deprecated - use `mode` option instead
* @default true
*/
changeSet?: boolean;

/**
* Auto mode will first attempt to use change-set mode, and if any error should occur it will fallback to template-only mode.
* Change-set mode will use a change-set to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.
* Template-only mode compares the current local template with template applied on the stack
*
* @default 'auto'
*/
mode?: 'auto' | 'change-set' | 'template-only';
}

interface CfnDeployOptions {
Expand Down
1 change: 1 addition & 0 deletions packages/aws-cdk/lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export async function exec(args: string[], synthesizer?: Synthesizer): Promise<n
compareAgainstProcessedTemplate: args.processed,
quiet: args.quiet,
changeSet: args['change-set'],
mode: args.mode,
toolkitStackName: toolkitStackName,
});

Expand Down
13 changes: 12 additions & 1 deletion packages/aws-cdk/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,18 @@ export async function makeConfig(): Promise<CliConfig> {
'fail': { type: 'boolean', desc: 'Fail with exit code 1 in case of diff' },
'processed': { type: 'boolean', desc: 'Whether to compare against the template with Transforms already processed', default: false },
'quiet': { type: 'boolean', alias: 'q', desc: 'Do not print stack name and default message when there is no diff to stdout', default: false },
'change-set': { type: 'boolean', alias: 'changeset', desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.', default: true },
'change-set': { type: 'boolean', alias: 'changeset', desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role', conflicts: 'mode', deprecated: 'Use mode=auto or mode=template-only instead', default: true },
'mode': {
type: 'string',
choices: ['auto', 'change-set', 'template-only'],
default: 'auto',
conflicts: 'change-set',
requiresArg: true,
desc: 'How to perform the the diff operation. ' +
'Auto mode will first attempt to use change-set mode, and if any error should occur it will fallback to template-only mode. ' +
'Change-set mode will use a change-set to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role. Unhandled errors in change-set creation will return a non-zero exit code ' +
'Template-only mode compares the current local template with template applied on the stack',
},
},
},
metadata: {
Expand Down
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/convert-to-user-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ export function convertYargsToUserInput(args: any): UserInput {
processed: args.processed,
quiet: args.quiet,
changeSet: args.changeSet,
mode: args.mode,
STACKS: args.STACKS,
};
break;
Expand Down Expand Up @@ -396,6 +397,7 @@ export function convertConfigToUserInput(config: any): UserInput {
processed: config.diff?.processed,
quiet: config.diff?.quiet,
changeSet: config.diff?.changeSet,
mode: config.diff?.mode,
};
const metadataOptions = {};
const acknowledgeOptions = {};
Expand Down
12 changes: 11 additions & 1 deletion packages/aws-cdk/lib/parse-command-line-arguments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -709,7 +709,17 @@ export function parseCommandLineArguments(args: Array<string>): any {
default: true,
type: 'boolean',
alias: 'changeset',
desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.',
desc: 'Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role',
conflicts: 'mode',
deprecated: 'Use mode=auto or mode=template-only instead',
})
.option('mode', {
default: 'auto',
type: 'string',
choices: ['auto', 'change-set', 'template-only'],
conflicts: 'change-set',
requiresArg: true,
desc: 'How to perform the the diff operation. Auto mode will first attempt to use change-set mode, and if any error should occur it will fallback to template-only mode. Change-set mode will use a change-set to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role. Unhandled errors in change-set creation will return a non-zero exit code Template-only mode compares the current local template with template applied on the stack',
}),
)
.command('metadata [STACK]', 'Returns all metadata associated with this stack')
Expand Down
10 changes: 9 additions & 1 deletion packages/aws-cdk/lib/user-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1090,14 +1090,22 @@ export interface DiffOptions {
readonly quiet?: boolean;

/**
* Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role.
* Whether to create a changeset to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role
*
* aliases: changeset
*
* @deprecated Use mode=auto or mode=template-only instead
* @default - true
*/
readonly changeSet?: boolean;

/**
* How to perform the the diff operation. Auto mode will first attempt to use change-set mode, and if any error should occur it will fallback to template-only mode. Change-set mode will use a change-set to analyze resource replacements. In this mode, diff will use the deploy role instead of the lookup role. Unhandled errors in change-set creation will return a non-zero exit code Template-only mode compares the current local template with template applied on the stack
*
* @default - "auto"
*/
readonly mode?: string;

/**
* Positional argument for diff
*/
Expand Down
Loading
Loading