Skip to content

Commit d718425

Browse files
authored
feat: take all neighbors into account when computing resource digests (#713)
The current definition of the digest of a resource computes a hash of its properties plus the hashes of its dependencies. But if we have a graph like: A --> B C --> D where `B` and `D` are identical, the current algorithm will compute the same digest for them, even if `A` is different from `C` (and therefore have different digests). This is undesirable, as some cases are considered ambiguous, even if we could very well tell them apart. Real world example: <img width="326" height="182" alt="non-ambiguous" src="https://github.com/user-attachments/assets/32e442c6-9605-43b4-bdd5-a745f1a0426f" /> `Role1` and `Role2` tend to be identical, but they can nevertheless be told apart because at least the functions will have different properties. So, even if both roles are renamed, for example, we can still provide a valid mapping. This change improves the algorithm by also taking into account all neighbors of a resource for digest computation. It does this by doing a two pass over the graph: first navigating in topological order and computing the digests, then navigating in the opposite order and doing the same. So each resource has two digests, that are in turn, used to compute the final digest. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent d62b991 commit d718425

File tree

4 files changed

+190
-22
lines changed

4 files changed

+190
-22
lines changed

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

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Environment } from '@aws-cdk/cx-api';
22
import type { CloudFormationStack } from './cloudformation';
33
import { ResourceLocation, ResourceMapping } from './cloudformation';
4+
import type { GraphDirection } from './digest';
45
import { computeResourceDigests } from './digest';
56
import { ToolkitError } from '../../toolkit/toolkit-error';
67
import { equalSets } from '../../util/sets';
@@ -29,9 +30,12 @@ export class RefactoringContext {
2930

3031
constructor(props: RefactorManagerOptions) {
3132
this.environment = props.environment;
32-
const moves = resourceMoves(props.deployedStacks, props.localStacks);
33-
const [nonAmbiguousMoves, ambiguousMoves] = partitionByAmbiguity(props.overrides ?? [], moves);
33+
const moves = resourceMoves(props.deployedStacks, props.localStacks, 'direct');
34+
const additionalOverrides = structuralOverrides(props.deployedStacks, props.localStacks);
35+
const overrides = (props.overrides ?? []).concat(additionalOverrides);
36+
const [nonAmbiguousMoves, ambiguousMoves] = partitionByAmbiguity(overrides, moves);
3437
this.ambiguousMoves = ambiguousMoves;
38+
3539
this._mappings = resourceMappings(nonAmbiguousMoves);
3640
}
3741

@@ -48,9 +52,32 @@ export class RefactoringContext {
4852
}
4953
}
5054

51-
function resourceMoves(before: CloudFormationStack[], after: CloudFormationStack[]): ResourceMove[] {
52-
const digestsBefore = resourceDigests(before);
53-
const digestsAfter = resourceDigests(after);
55+
/**
56+
* Generates an automatic list of overrides that can be deduced from the structure of the opposite resource graph.
57+
* Suppose we have the following resource graph:
58+
*
59+
* A --> B
60+
* C --> D
61+
*
62+
* such that B and D are identical, but A is different from C. Then digest(B) = digest(D). If both resources are moved,
63+
* we have an ambiguity. But if we reverse the arrows:
64+
*
65+
* A <-- B
66+
* C <-- D
67+
*
68+
* then digest(B) ≠ digest(D), because they now have different dependencies. If we compute the mappings from this
69+
* opposite graph, we can use them as a set of overrides to disambiguate the original moves.
70+
*
71+
*/
72+
function structuralOverrides(deployedStacks: CloudFormationStack[], localStacks: CloudFormationStack[]): ResourceMapping[] {
73+
const moves = resourceMoves(deployedStacks, localStacks, 'opposite');
74+
const [nonAmbiguousMoves] = partitionByAmbiguity([], moves);
75+
return resourceMappings(nonAmbiguousMoves);
76+
}
77+
78+
function resourceMoves(before: CloudFormationStack[], after: CloudFormationStack[], direction: GraphDirection): ResourceMove[] {
79+
const digestsBefore = resourceDigests(before, direction);
80+
const digestsAfter = resourceDigests(after, direction);
5481

5582
const stackNames = (stacks: CloudFormationStack[]) => stacks.map((s) => s.stackName).sort().join(', ');
5683
if (!isomorphic(digestsBefore, digestsAfter)) {
@@ -119,14 +146,14 @@ function zip(
119146
/**
120147
* Computes a list of pairs [digest, location] for each resource in the stack.
121148
*/
122-
function resourceDigests(stacks: CloudFormationStack[]): Record<string, ResourceLocation[]> {
149+
function resourceDigests(stacks: CloudFormationStack[], direction: GraphDirection): Record<string, ResourceLocation[]> {
123150
// index stacks by name
124151
const stacksByName = new Map<string, CloudFormationStack>();
125152
for (const stack of stacks) {
126153
stacksByName.set(stack.stackName, stack);
127154
}
128155

129-
const digests = computeResourceDigests(stacks);
156+
const digests = computeResourceDigests(stacks, direction);
130157

131158
return groupByKey(
132159
Object.entries(digests).map(([loc, digest]) => {

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

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import * as crypto from 'node:crypto';
22
import type { CloudFormationResource, CloudFormationStack } from './cloudformation';
33
import { ResourceGraph } from './graph';
44

5+
export type GraphDirection =
6+
'direct' // Edge A -> B mean that A depends on B
7+
| 'opposite'; // Edge A -> B mean that B depends on A
8+
59
/**
610
* Computes the digest for each resource in the template.
711
*
@@ -19,28 +23,38 @@ import { ResourceGraph } from './graph';
1923
* CloudFormation template form a directed acyclic graph, this function is
2024
* well-defined.
2125
*/
22-
export function computeResourceDigests(stacks: CloudFormationStack[]): Record<string, string> {
26+
export function computeResourceDigests(stacks: CloudFormationStack[], direction: GraphDirection = 'direct'): Record<string, string> {
2327
const exports: { [p: string]: { stackName: string; value: any } } = Object.fromEntries(
2428
stacks.flatMap((s) =>
2529
Object.values(s.template.Outputs ?? {})
2630
.filter((o) => o.Export != null && typeof o.Export.Name === 'string')
27-
.map((o) => [o.Export.Name, { stackName: s.stackName, value: o.Value }] as [string, { stackName: string; value: any }]),
31+
.map(
32+
(o) =>
33+
[o.Export.Name, { stackName: s.stackName, value: o.Value }] as [string, { stackName: string; value: any }],
34+
),
2835
),
2936
);
3037

3138
const resources = Object.fromEntries(
3239
stacks.flatMap((s) => {
3340
return Object.entries(s.template.Resources ?? {})
3441
.filter(([_, res]) => res.Type !== 'AWS::CDK::Metadata')
35-
.map(
36-
([id, res]) => [`${s.stackName}.${id}`, res] as [string, CloudFormationResource],
37-
);
42+
.map(([id, res]) => [`${s.stackName}.${id}`, res] as [string, CloudFormationResource]);
3843
}),
3944
);
4045

41-
const graph = new ResourceGraph(stacks);
46+
const graph = direction == 'direct'
47+
? ResourceGraph.fromStacks(stacks)
48+
: ResourceGraph.fromStacks(stacks).opposite();
49+
50+
return computeDigestsInTopologicalOrder(graph, resources, exports);
51+
}
52+
53+
function computeDigestsInTopologicalOrder(
54+
graph: ResourceGraph,
55+
resources: Record<string, CloudFormationResource>,
56+
exports: Record<string, { stackName: string; value: any }>): Record<string, string> {
4257
const nodes = graph.sortedNodes.filter(n => resources[n] != null);
43-
// 4. Compute digests in sorted order
4458
const result: Record<string, string> = {};
4559
for (const id of nodes) {
4660
const resource = resources[id];

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

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import { ToolkitError } from '../../toolkit/toolkit-error';
55
* An immutable directed graph of resources from multiple CloudFormation stacks.
66
*/
77
export class ResourceGraph {
8-
private readonly edges: Record<string, Set<string>> = {};
9-
private readonly reverseEdges: Record<string, Set<string>> = {};
10-
11-
constructor(stacks: Omit<CloudFormationStack, 'environment'>[]) {
8+
public static fromStacks(stacks: Omit<CloudFormationStack, 'environment'>[]): ResourceGraph {
129
const exports: { [p: string]: { stackName: string; value: any } } = Object.fromEntries(
1310
stacks.flatMap((s) =>
1411
Object.values(s.template.Outputs ?? {})
@@ -35,9 +32,11 @@ export class ResourceGraph {
3532
);
3633

3734
// 1. Build adjacency lists
35+
const edges: Record<string, Set<string>> = {};
36+
const reverseEdges: Record<string, Set<string>> = {};
3837
for (const id of Object.keys(resources)) {
39-
this.edges[id] = new Set();
40-
this.reverseEdges[id] = new Set();
38+
edges[id] = new Set();
39+
reverseEdges[id] = new Set();
4140
}
4241

4342
// 2. Detect dependencies by searching for Ref/Fn::GetAtt
@@ -84,11 +83,21 @@ export class ResourceGraph {
8483
const deps = findDependencies(stackName, res || {});
8584
for (const dep of deps) {
8685
if (dep in resources && dep !== id) {
87-
this.edges[id].add(dep);
88-
this.reverseEdges[dep].add(id);
86+
edges[id].add(dep);
87+
reverseEdges[dep].add(id);
8988
}
9089
}
9190
}
91+
92+
return new ResourceGraph(edges, reverseEdges);
93+
}
94+
95+
private readonly edges: Record<string, Set<string>> = {};
96+
private readonly reverseEdges: Record<string, Set<string>> = {};
97+
98+
private constructor(edges: Record<string, Set<string>>, reverseEdges: Record<string, Set<string>>) {
99+
this.edges = edges;
100+
this.reverseEdges = reverseEdges;
92101
}
93102

94103
/**
@@ -129,4 +138,11 @@ export class ResourceGraph {
129138
}
130139
return Array.from(this.edges[node] || []);
131140
}
141+
142+
/**
143+
* Returns another graph with the same nodes, but with the edges inverted
144+
*/
145+
public opposite(): ResourceGraph {
146+
return new ResourceGraph(this.reverseEdges, this.edges);
147+
}
132148
}

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,117 @@ describe('typed mappings', () => {
402402
]);
403403
});
404404

405+
test('ambiguous pairs that can be disambiguated by the structure', () => {
406+
const stack1 = {
407+
environment,
408+
stackName: 'Foo',
409+
template: {
410+
Resources: {
411+
Bucket1: {
412+
Type: 'AWS::S3::Bucket',
413+
Properties: {},
414+
},
415+
Bucket2: {
416+
Type: 'AWS::S3::Bucket',
417+
Properties: {},
418+
},
419+
Depender1: {
420+
Type: 'AWS::Foo::Foo',
421+
Properties: {
422+
SomeProp: { Ref: 'Bucket1' },
423+
},
424+
},
425+
Depender2: {
426+
Type: 'AWS::Bar::Bar',
427+
Properties: {
428+
SomeProp: { Ref: 'Bucket2' },
429+
},
430+
},
431+
},
432+
},
433+
};
434+
435+
const stack2 = {
436+
environment,
437+
stackName: 'Bar',
438+
template: {
439+
Resources: {
440+
Bucket3: {
441+
Type: 'AWS::S3::Bucket',
442+
Properties: {},
443+
},
444+
Bucket4: {
445+
Type: 'AWS::S3::Bucket',
446+
Properties: {},
447+
},
448+
Depender1: {
449+
Type: 'AWS::Foo::Foo',
450+
Properties: {
451+
SomeProp: { Ref: 'Bucket3' },
452+
},
453+
},
454+
Depender2: {
455+
Type: 'AWS::Bar::Bar',
456+
Properties: {
457+
SomeProp: { Ref: 'Bucket4' },
458+
},
459+
},
460+
},
461+
},
462+
};
463+
464+
const context = new RefactoringContext({
465+
environment,
466+
deployedStacks: [stack1],
467+
localStacks: [stack2],
468+
});
469+
expect(context.ambiguousPaths.length).toEqual(0);
470+
expect(context.mappings.map(toCfnMapping)).toEqual([
471+
// Despite Bucket1 and Bucket2 being identical, we could still disambiguate
472+
// them based on the resources that depend on them.
473+
{
474+
Destination: {
475+
LogicalResourceId: 'Bucket3',
476+
StackName: 'Bar',
477+
},
478+
Source: {
479+
LogicalResourceId: 'Bucket1',
480+
StackName: 'Foo',
481+
},
482+
},
483+
{
484+
Destination: {
485+
LogicalResourceId: 'Bucket4',
486+
StackName: 'Bar',
487+
},
488+
Source: {
489+
LogicalResourceId: 'Bucket2',
490+
StackName: 'Foo',
491+
},
492+
},
493+
{
494+
Destination: {
495+
LogicalResourceId: 'Depender1',
496+
StackName: 'Bar',
497+
},
498+
Source: {
499+
LogicalResourceId: 'Depender1',
500+
StackName: 'Foo',
501+
},
502+
},
503+
{
504+
Destination: {
505+
LogicalResourceId: 'Depender2',
506+
StackName: 'Bar',
507+
},
508+
Source: {
509+
LogicalResourceId: 'Depender2',
510+
StackName: 'Foo',
511+
},
512+
},
513+
]);
514+
});
515+
405516
test('combines addition, deletion, update, and rename', () => {
406517
const stack1 = {
407518
environment,

0 commit comments

Comments
 (0)