Skip to content

Commit 6beac2b

Browse files
otaviomacedogithub-actions
andauthored
feat: diff shows moved resources (#708)
When a resource is moved from one location to another, the `diff` command will show two entries: an addition and a removal. This PR augments those entries to show where they moved to and from, respectively: <img width="1440" height="709" alt="Drawing 2025-07-15 14 35 02 excalidraw" src="https://github.com/user-attachments/assets/005c27a0-b433-4203-a3ee-7bfa893d3f47" /> This information is not shown by default. To enable it, the user has to pass `--include-moves` to the CLI, and `includeMoves: true` to the toolkit. Closes #362 Depends on #679. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license --------- Signed-off-by: github-actions <[email protected]> Co-authored-by: github-actions <[email protected]>
1 parent d718425 commit 6beac2b

File tree

18 files changed

+266
-20
lines changed

18 files changed

+266
-20
lines changed

packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,12 @@ export interface Resource {
547547
[key: string]: any;
548548
}
549549

550+
export interface Move {
551+
readonly direction: 'from' | 'to';
552+
readonly stackName: string;
553+
readonly resourceLogicalId: string;
554+
}
555+
550556
/**
551557
* Change to a single resource between two CloudFormation templates
552558
*
@@ -568,6 +574,8 @@ export class ResourceDifference implements IDifference<Resource> {
568574
*/
569575
public isImport?: boolean;
570576

577+
public move?: Move;
578+
571579
/** Property-level changes on the resource */
572580
private readonly propertyDiffs: { [key: string]: PropertyDifference<any> };
573581

packages/@aws-cdk/cloudformation-diff/lib/format.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { format } from 'util';
22
import * as chalk from 'chalk';
3-
import type { DifferenceCollection, TemplateDiff } from './diff/types';
3+
import type { DifferenceCollection, Move, TemplateDiff } from './diff/types';
44
import { deepEqual } from './diff/util';
55
import type { Difference, ResourceDifference } from './diff-template';
66
import { isPropertyDifference, ResourceImpact } from './diff-template';
@@ -166,8 +166,15 @@ export class Formatter {
166166

167167
const resourceType = diff.isRemoval ? diff.oldResourceType : diff.newResourceType;
168168

169-
// eslint-disable-next-line @stylistic/max-len
170-
this.print(`${this.formatResourcePrefix(diff)} ${this.formatValue(resourceType, chalk.cyan)} ${this.formatLogicalId(logicalId)} ${this.formatImpact(diff.changeImpact)}`.trimEnd());
169+
const message = [
170+
this.formatResourcePrefix(diff),
171+
this.formatValue(resourceType, chalk.cyan),
172+
this.formatLogicalId(logicalId),
173+
this.formatImpact(diff.changeImpact),
174+
this.formatMove(diff.move),
175+
].filter(Boolean).join(' ');
176+
177+
this.print(message);
171178

172179
if (diff.isUpdate) {
173180
const differenceCount = diff.differenceCount;
@@ -239,6 +246,10 @@ export class Formatter {
239246
}
240247
}
241248

249+
private formatMove(move?: Move): string {
250+
return !move ? '' : chalk.yellow('(OR', chalk.italic(chalk.bold('move')), `${move.direction} ${move.stackName}.${move.resourceLogicalId} via refactoring)`);
251+
}
252+
242253
/**
243254
* Renders a tree of differences under a particular name.
244255
* @param name - the name of the root of the tree.

packages/@aws-cdk/toolkit-lib/lib/actions/diff/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,12 @@ export interface DiffOptions {
129129
* @default 3
130130
*/
131131
readonly contextLines?: number;
132+
133+
/**
134+
* Whether to include resource moves in the diff. These are the same moves that are detected
135+
* by the `refactor` command.
136+
*
137+
* @default false
138+
*/
139+
readonly includeMoves?: boolean;
132140
}

packages/@aws-cdk/toolkit-lib/lib/actions/diff/private/helpers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type { ResourcesToImport } from '../../../api/resource-import';
1313
import { removeNonImportResources, ResourceMigrator } from '../../../api/resource-import';
1414
import { ToolkitError } from '../../../toolkit/toolkit-error';
1515
import { deserializeStructure, formatErrorMessage } from '../../../util';
16+
import { mappingsByEnvironment } from '../../refactor/index';
1617

1718
export function prepareDiff(
1819
ioHelper: IoHelper,
@@ -67,6 +68,10 @@ async function cfnDiff(
6768
const templateInfos = [];
6869
const methodOptions = (options.method?.options ?? {}) as ChangeSetDiffOptions;
6970

71+
const allMappings = options.includeMoves
72+
? await mappingsByEnvironment(stacks.stackArtifacts, sdkProvider, true)
73+
: [];
74+
7075
// Compare N stacks against deployed templates
7176
for (const stack of stacks.stackArtifacts) {
7277
const templateWithNestedStacks = await deployments.readCurrentTemplateWithNestedStacks(
@@ -93,12 +98,17 @@ async function cfnDiff(
9398
methodOptions.importExistingResources,
9499
) : undefined;
95100

101+
const mappings = allMappings.find(m =>
102+
m.environment.region === stack.environment.region && m.environment.account === stack.environment.account,
103+
)?.mappings ?? {};
104+
96105
templateInfos.push({
97106
oldTemplate: currentTemplate,
98107
newTemplate: stack,
99108
isImport: !!resourcesToImport,
100109
nestedStacks,
101110
changeSet,
111+
mappings,
102112
});
103113
}
104114

packages/@aws-cdk/toolkit-lib/lib/actions/refactor/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import type * as cxapi from '@aws-cdk/cx-api';
12
import type { StackSelector } from '../../api';
3+
import type { SdkProvider } from '../../api/aws-auth/sdk-provider';
24
import type { ExcludeList } from '../../api/refactoring';
3-
import { InMemoryExcludeList, NeverExclude } from '../../api/refactoring';
5+
import { groupStacks, InMemoryExcludeList, NeverExclude, RefactoringContext } from '../../api/refactoring';
46
import { ToolkitError } from '../../toolkit/toolkit-error';
57

68
type MappingType = 'auto' | 'explicit';
@@ -142,3 +144,28 @@ export function parseMappingGroups(s: string) {
142144
}
143145
}
144146
}
147+
148+
export interface EnvironmentSpecificMappings {
149+
readonly environment: cxapi.Environment;
150+
readonly mappings: Record<string, string>;
151+
}
152+
153+
export async function mappingsByEnvironment(
154+
stackArtifacts: cxapi.CloudFormationStackArtifact[],
155+
sdkProvider: SdkProvider,
156+
ignoreModifications?: boolean,
157+
): Promise<EnvironmentSpecificMappings[]> {
158+
const groups = await groupStacks(sdkProvider, stackArtifacts, []);
159+
return groups.map((group) => {
160+
const context = new RefactoringContext({
161+
...group,
162+
ignoreModifications,
163+
});
164+
return {
165+
environment: context.environment,
166+
mappings: Object.fromEntries(
167+
context.mappings.map((m) => [m.source.toLocationString(), m.destination.toLocationString()]),
168+
),
169+
};
170+
});
171+
}

packages/@aws-cdk/toolkit-lib/lib/api/diff/diff-formatter.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
formatSecurityChanges,
66
fullDiff,
77
mangleLikeCloudFormation,
8+
type ResourceDifference,
89
type TemplateDiff,
910
} from '@aws-cdk/cloudformation-diff';
1011
import type * as cxapi from '@aws-cdk/cx-api';
@@ -122,6 +123,14 @@ export interface TemplateInfo {
122123
readonly nestedStacks?: {
123124
[nestedStackLogicalId: string]: NestedStackTemplates;
124125
};
126+
127+
/**
128+
* Mappings of old locations to new locations. If these are provided,
129+
* for all resources that were moved, their corresponding addition
130+
* and removal lines will be augmented with the location they were
131+
* moved fom and to, respectively.
132+
*/
133+
readonly mappings?: Record<string, string>;
125134
}
126135

127136
/**
@@ -134,6 +143,7 @@ export class DiffFormatter {
134143
private readonly changeSet?: any;
135144
private readonly nestedStacks: { [nestedStackLogicalId: string]: NestedStackTemplates } | undefined;
136145
private readonly isImport: boolean;
146+
private readonly mappings: Record<string, string>;
137147

138148
/**
139149
* Stores the TemplateDiffs that get calculated in this DiffFormatter,
@@ -148,6 +158,7 @@ export class DiffFormatter {
148158
this.changeSet = props.templateInfo.changeSet;
149159
this.nestedStacks = props.templateInfo.nestedStacks;
150160
this.isImport = props.templateInfo.isImport ?? false;
161+
this.mappings = props.templateInfo.mappings ?? {};
151162
}
152163

153164
public get diffs() {
@@ -159,16 +170,38 @@ export class DiffFormatter {
159170
* If it creates the diff, it stores the result in a map for
160171
* easier retrieval later.
161172
*/
162-
private diff(stackName?: string, oldTemplate?: any) {
173+
private diff(stackName?: string, oldTemplate?: any, mappings: Record<string, string> = {}) {
163174
const realStackName = stackName ?? this.stackName;
164175

165176
if (!this._diffs[realStackName]) {
166-
this._diffs[realStackName] = fullDiff(
177+
const templateDiff = fullDiff(
167178
oldTemplate ?? this.oldTemplate,
168179
this.newTemplate.template,
169180
this.changeSet,
170181
this.isImport,
171182
);
183+
184+
const setMove = (change: ResourceDifference, direction: 'from' | 'to', location?: string)=> {
185+
if (location != null) {
186+
const [sourceStackName, sourceLogicalId] = location.split('.');
187+
change.move = {
188+
direction,
189+
stackName: sourceStackName,
190+
resourceLogicalId: sourceLogicalId,
191+
};
192+
}
193+
};
194+
195+
templateDiff.resources.forEachDifference((id, change) => {
196+
const location = `${realStackName}.${id}`;
197+
if (change.isAddition && Object.values(mappings).includes(location)) {
198+
setMove(change, 'from', Object.keys(mappings).find(k => mappings[k] === location));
199+
} else if (change.isRemoval && Object.keys(mappings).includes(location)) {
200+
setMove(change, 'to', mappings[location]);
201+
}
202+
});
203+
204+
this._diffs[realStackName] = templateDiff;
172205
}
173206
return this._diffs[realStackName];
174207
}
@@ -199,6 +232,7 @@ export class DiffFormatter {
199232
this.stackName,
200233
this.nestedStacks,
201234
options,
235+
this.mappings,
202236
);
203237
}
204238

@@ -207,8 +241,9 @@ export class DiffFormatter {
207241
stackName: string,
208242
nestedStackTemplates: { [nestedStackLogicalId: string]: NestedStackTemplates } | undefined,
209243
options: ReusableStackDiffOptions,
244+
mappings: Record<string, string> = {},
210245
) {
211-
let diff = this.diff(stackName, oldTemplate);
246+
let diff = this.diff(stackName, oldTemplate, mappings);
212247

213248
// The stack diff is formatted via `Formatter`, which takes in a stream
214249
// and sends its output directly to that stream. To facilitate use of the
@@ -279,6 +314,7 @@ export class DiffFormatter {
279314
nestedStack.physicalName ?? nestedStackLogicalId,
280315
nestedStack.nestedStackTemplates,
281316
options,
317+
this.mappings,
282318
);
283319
numStacksWithChanges += nextDiff.numStacksWithChanges;
284320
formattedDiff += nextDiff.formattedDiff;

packages/@aws-cdk/toolkit-lib/lib/api/refactoring/cloudformation.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,19 @@ export class ResourceLocation {
3131
}
3232

3333
public toPath(): string {
34-
const stack = this.stack;
35-
const resource = stack.template.Resources?.[this.logicalResourceId];
34+
const resource = this.stack.template.Resources?.[this.logicalResourceId];
3635
const result = resource?.Metadata?.['aws:cdk:path'];
3736

3837
if (result != null) {
3938
return result;
4039
}
4140

4241
// If the path is not available, we can use stack name and logical ID
43-
return `${stack.stackName}.${this.logicalResourceId}`;
42+
return this.toLocationString();
43+
}
44+
45+
public toLocationString() {
46+
return `${this.stack.stackName}.${this.logicalResourceId}`;
4447
}
4548

4649
public getType(): string {

packages/@aws-cdk/toolkit-lib/lib/api/refactoring/context.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import { equalSets } from '../../util/sets';
1313
*/
1414
type ResourceMove = [ResourceLocation[], ResourceLocation[]];
1515

16-
export interface RefactorManagerOptions {
16+
export interface RefactoringContextOptions {
1717
environment: Environment;
1818
localStacks: CloudFormationStack[];
1919
deployedStacks: CloudFormationStack[];
2020
overrides?: ResourceMapping[];
21+
ignoreModifications?: boolean;
2122
}
2223

2324
/**
@@ -28,9 +29,9 @@ export class RefactoringContext {
2829
private readonly ambiguousMoves: ResourceMove[] = [];
2930
public readonly environment: Environment;
3031

31-
constructor(props: RefactorManagerOptions) {
32+
constructor(props: RefactoringContextOptions) {
3233
this.environment = props.environment;
33-
const moves = resourceMoves(props.deployedStacks, props.localStacks, 'direct');
34+
const moves = resourceMoves(props.deployedStacks, props.localStacks, 'direct', props.ignoreModifications);
3435
const additionalOverrides = structuralOverrides(props.deployedStacks, props.localStacks);
3536
const overrides = (props.overrides ?? []).concat(additionalOverrides);
3637
const [nonAmbiguousMoves, ambiguousMoves] = partitionByAmbiguity(overrides, moves);
@@ -70,20 +71,28 @@ export class RefactoringContext {
7071
*
7172
*/
7273
function structuralOverrides(deployedStacks: CloudFormationStack[], localStacks: CloudFormationStack[]): ResourceMapping[] {
73-
const moves = resourceMoves(deployedStacks, localStacks, 'opposite');
74+
const moves = resourceMoves(deployedStacks, localStacks, 'opposite', true);
7475
const [nonAmbiguousMoves] = partitionByAmbiguity([], moves);
7576
return resourceMappings(nonAmbiguousMoves);
7677
}
7778

78-
function resourceMoves(before: CloudFormationStack[], after: CloudFormationStack[], direction: GraphDirection): ResourceMove[] {
79+
function resourceMoves(
80+
before: CloudFormationStack[],
81+
after: CloudFormationStack[],
82+
direction: GraphDirection = 'direct',
83+
ignoreModifications: boolean = false): ResourceMove[] {
7984
const digestsBefore = resourceDigests(before, direction);
8085
const digestsAfter = resourceDigests(after, direction);
8186

82-
const stackNames = (stacks: CloudFormationStack[]) => stacks.map((s) => s.stackName).sort().join(', ');
83-
if (!isomorphic(digestsBefore, digestsAfter)) {
87+
const stackNames = (stacks: CloudFormationStack[]) =>
88+
stacks
89+
.map((s) => s.stackName)
90+
.sort()
91+
.join(', ');
92+
if (!(ignoreModifications || isomorphic(digestsBefore, digestsAfter))) {
8493
const message = [
8594
'A refactor operation cannot add, remove or update resources. Only resource moves and renames are allowed. ',
86-
'Run \'cdk diff\' to compare the local templates to the deployed stacks.\n',
95+
"Run 'cdk diff' to compare the local templates to the deployed stacks.\n",
8796
`Deployed stacks: ${stackNames(before)}`,
8897
`Local stacks: ${stackNames(after)}`,
8998
];

packages/@aws-cdk/toolkit-lib/lib/api/refactoring/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { MappingGroup } from '../../actions';
1717
import { ToolkitError } from '../../toolkit/toolkit-error';
1818

1919
export * from './exclude';
20+
export * from './context';
2021

2122
interface StackGroup {
2223
environment: cxapi.Environment;

0 commit comments

Comments
 (0)