Skip to content

Commit 6cde334

Browse files
authored
feat(refactor): resources with the same physical ID are considered equivalent (#360)
Incorporate the physical ID into the computation of resource digests. This gives the user more flexibility: if a resource with physical ID set is moved and updated at the same time, the algorithm can still detect that it is the same resource. --- By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license
1 parent acbbea1 commit 6cde334

File tree

2 files changed

+275
-8
lines changed

2 files changed

+275
-8
lines changed

packages/@aws-cdk/tmp-toolkit-helpers/src/api/refactoring/digest.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,24 @@
11
import * as crypto from 'node:crypto';
2+
import { loadResourceModel } from '@aws-cdk/cloudformation-diff/lib/diff/util';
23
import type { CloudFormationTemplate } from './cloudformation';
34

45
/**
56
* Computes the digest for each resource in the template.
67
*
78
* Conceptually, the digest is computed as:
89
*
9-
* digest(resource) = hash(type + properties + dependencies.map(d))
10+
* d(resource) = hash(type + physicalId) , if physicalId is defined
11+
* = hash(type + properties + dependencies.map(d)) , otherwise
1012
*
11-
* where `hash` is a cryptographic hash function. In other words, the digest of a
12-
* resource is computed from its type, its own properties (that is, excluding
13-
* properties that refer to other resources), and the digests of each of its
14-
* dependencies.
13+
* where `hash` is a cryptographic hash function. In other words, if a resource has
14+
* a physical ID, we use the physical ID plus its type to uniquely identify
15+
* that resource. In this case, the digest can be computed from these two fields
16+
* alone. A corollary is that such resources can be renamed and have their
17+
* properties updated at the same time, and still be considered equivalent.
18+
*
19+
* Otherwise, the digest is computed from its type, its own properties (that is,
20+
* excluding properties that refer to other resources), and the digests of each of
21+
* its dependencies.
1522
*
1623
* The digest of a resource, defined recursively this way, remains stable even if
1724
* one or more of its dependencies gets renamed. Since the resources in a
@@ -82,9 +89,28 @@ export function computeResourceDigests(template: CloudFormationTemplate): Record
8289
const result: Record<string, string> = {};
8390
for (const id of order) {
8491
const resource = resources[id];
85-
const depDigests = Array.from(graph[id]).map((d) => result[d]);
86-
const propsWithoutRefs = hashObject(stripReferences(stripConstructPath(resource)));
87-
const toHash = resource.Type + propsWithoutRefs + depDigests.join('');
92+
const resourceProperties = resource.Properties ?? {};
93+
const model = loadResourceModel(resource.Type);
94+
const identifier = intersection(Object.keys(resourceProperties), model?.primaryIdentifier ?? []);
95+
let toHash: string;
96+
97+
if (identifier.length === model?.primaryIdentifier?.length) {
98+
// The resource has a physical ID defined, so we can
99+
// use the ID and the type as the identity of the resource.
100+
toHash =
101+
resource.Type +
102+
identifier
103+
.sort()
104+
.map((attr) => JSON.stringify(resourceProperties[attr]))
105+
.join('');
106+
} else {
107+
// The resource does not have a physical ID defined, so we need to
108+
// compute the digest based on its properties and dependencies.
109+
const depDigests = Array.from(graph[id]).map((d) => result[d]);
110+
const propertiesHash = hashObject(stripReferences(stripConstructPath(resource)));
111+
toHash = resource.Type + propertiesHash + depDigests.join('');
112+
}
113+
88114
result[id] = crypto.createHash('sha256').update(toHash).digest('hex');
89115
}
90116

@@ -151,3 +177,7 @@ function stripConstructPath(resource: any): any {
151177
delete copy.Metadata['aws:cdk:path'];
152178
return copy;
153179
}
180+
181+
function intersection<T>(a: T[], b: T[]): T[] {
182+
return a.filter((value) => b.includes(value));
183+
}

packages/aws-cdk/test/api/refactoring/refactoring.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
const mockLoadResourceModel = jest.fn();
2+
jest.mock('@aws-cdk/cloudformation-diff/lib/diff/util', () => ({
3+
loadResourceModel: mockLoadResourceModel,
4+
}));
5+
16
import {
27
GetTemplateCommand,
38
ListStacksCommand,
@@ -293,6 +298,238 @@ describe('computeResourceDigests', () => {
293298
const result = computeResourceDigests(template);
294299
expect(result['Q1']).toBe(result['Q2']);
295300
});
301+
302+
test('uses physical ID if present', () => {
303+
mockLoadResourceModel.mockReturnValue({
304+
primaryIdentifier: ['FooName']
305+
});
306+
307+
const template = {
308+
Resources: {
309+
Foo1: {
310+
Type: 'AWS::S3::Foo',
311+
Properties: {
312+
FooName: 'XXXXXXXXX',
313+
ShouldBeIgnored: true,
314+
},
315+
},
316+
Foo2: {
317+
Type: 'AWS::S3::Foo',
318+
Properties: {
319+
FooName: 'XXXXXXXXX',
320+
ShouldAlsoBeIgnored: true,
321+
},
322+
},
323+
},
324+
};
325+
326+
const result = computeResourceDigests(template);
327+
expect(result['Foo1']).toBe(result['Foo2']);
328+
});
329+
330+
test('uses physical ID if present - with dependencies', () => {
331+
mockLoadResourceModel.mockReturnValue({
332+
primaryIdentifier: ['FooName']
333+
});
334+
335+
const template = {
336+
Resources: {
337+
Foo1: {
338+
Type: 'AWS::S3::Foo',
339+
Properties: {
340+
FooName: 'XXXXXXXXX',
341+
ShouldBeIgnored: true,
342+
},
343+
},
344+
Foo2: {
345+
Type: 'AWS::S3::Foo',
346+
Properties: {
347+
FooName: 'XXXXXXXXX',
348+
ShouldAlsoBeIgnored: true,
349+
},
350+
},
351+
Bucket1: {
352+
Type: 'AWS::S3::Bucket',
353+
Properties: { Dep: { Ref: 'Foo1' } },
354+
},
355+
Bucket2: {
356+
Type: 'AWS::S3::Bucket',
357+
Properties: { Dep: { Ref: 'Foo2' } },
358+
},
359+
},
360+
};
361+
362+
const result = computeResourceDigests(template);
363+
expect(result['Bucket1']).toBe(result['Bucket2']);
364+
});
365+
366+
test('different physical IDs lead to different digests', () => {
367+
mockLoadResourceModel.mockReturnValue({
368+
primaryIdentifier: ['FooName']
369+
});
370+
371+
const template = {
372+
Resources: {
373+
Foo1: {
374+
Type: 'AWS::S3::Foo',
375+
Properties: {
376+
FooName: 'XXXXXXXXX',
377+
ShouldBeIgnored: true,
378+
},
379+
},
380+
Foo2: {
381+
Type: 'AWS::S3::Foo',
382+
Properties: {
383+
FooName: 'YYYYYYYYY',
384+
ShouldAlsoBeIgnored: true,
385+
},
386+
},
387+
},
388+
};
389+
390+
const result = computeResourceDigests(template);
391+
expect(result['Foo1']).not.toBe(result['Foo2']);
392+
});
393+
394+
test('primaryIdentifier is a composite field - different values', () => {
395+
mockLoadResourceModel.mockReturnValue({
396+
primaryIdentifier: ['FooName', 'BarName']
397+
});
398+
399+
const template = {
400+
Resources: {
401+
Foo1: {
402+
Type: 'AWS::S3::Foo',
403+
Properties: {
404+
FooName: 'XXXXXXXXX',
405+
BarName: 'YYYYYYYYY',
406+
ShouldBeIgnored: true,
407+
},
408+
},
409+
Foo2: {
410+
Type: 'AWS::S3::Foo',
411+
Properties: {
412+
FooName: 'XXXXXXXXX',
413+
BarName: 'ZZZZZZZZZ',
414+
ShouldAlsoBeIgnored: true,
415+
},
416+
},
417+
},
418+
};
419+
420+
const result = computeResourceDigests(template);
421+
expect(result['Foo1']).not.toBe(result['Foo2']);
422+
});
423+
424+
test('primaryIdentifier is a composite field - same value', () => {
425+
mockLoadResourceModel.mockReturnValue({
426+
primaryIdentifier: ['FooName', 'BarName']
427+
});
428+
429+
const template = {
430+
Resources: {
431+
Foo1: {
432+
Type: 'AWS::S3::Foo',
433+
Properties: {
434+
FooName: 'XXXXXXXXX',
435+
BarName: 'YYYYYYYYY',
436+
ShouldBeIgnored: true,
437+
},
438+
},
439+
Foo2: {
440+
Type: 'AWS::S3::Foo',
441+
Properties: {
442+
FooName: 'XXXXXXXXX',
443+
BarName: 'YYYYYYYYY',
444+
ShouldAlsoBeIgnored: true,
445+
},
446+
},
447+
},
448+
};
449+
450+
const result = computeResourceDigests(template);
451+
expect(result['Foo1']).toBe(result['Foo2']);
452+
});
453+
454+
test('primaryIdentifier is a composite field but not all of them are set in the resource', () => {
455+
mockLoadResourceModel.mockReturnValue({
456+
primaryIdentifier: ['FooName', 'BarName']
457+
});
458+
459+
const template = {
460+
Resources: {
461+
Foo1: {
462+
Type: 'AWS::S3::Foo',
463+
Properties: {
464+
FooName: 'XXXXXXXXX',
465+
ShouldBeIgnored: true,
466+
},
467+
},
468+
Foo2: {
469+
Type: 'AWS::S3::Foo',
470+
Properties: {
471+
FooName: 'XXXXXXXXX',
472+
ShouldAlsoBeIgnored: true,
473+
},
474+
},
475+
},
476+
};
477+
478+
const result = computeResourceDigests(template);
479+
expect(result['Foo1']).not.toBe(result['Foo2']);
480+
});
481+
482+
test('resource properties does not contain primaryIdentifier - different values', () => {
483+
mockLoadResourceModel.mockReturnValue({
484+
primaryIdentifier: ['FooName']
485+
});
486+
487+
const template = {
488+
Resources: {
489+
Foo1: {
490+
Type: 'AWS::S3::Foo',
491+
Properties: {
492+
ShouldNotBeIgnored: true,
493+
},
494+
},
495+
Foo2: {
496+
Type: 'AWS::S3::Foo',
497+
Properties: {
498+
ShouldNotBeIgnoredEither: true,
499+
},
500+
},
501+
},
502+
};
503+
504+
const result = computeResourceDigests(template);
505+
expect(result['Foo1']).not.toBe(result['Foo2']);
506+
});
507+
508+
test('resource properties does not contain primaryIdentifier - same value', () => {
509+
mockLoadResourceModel.mockReturnValue({
510+
primaryIdentifier: ['FooName']
511+
});
512+
513+
const template = {
514+
Resources: {
515+
Foo1: {
516+
Type: 'AWS::S3::Foo',
517+
Properties: {
518+
SomeProp: true,
519+
},
520+
},
521+
Foo2: {
522+
Type: 'AWS::S3::Foo',
523+
Properties: {
524+
SomeProp: true,
525+
},
526+
},
527+
},
528+
};
529+
530+
const result = computeResourceDigests(template);
531+
expect(result['Foo1']).toBe(result['Foo2']);
532+
});
296533
});
297534

298535
describe('typed mappings', () => {

0 commit comments

Comments
 (0)