Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(cli): cdk diff falsely reports resource replacements on trivial template changes #28336

Merged
merged 51 commits into from
Jan 4, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
b299958
can now create changeset during diff
comcalvi Dec 1, 2023
9f73241
working changeset diff for resource replacement, but not for property…
comcalvi Dec 4, 2023
b59e97f
property-level diff
comcalvi Dec 4, 2023
850fd0f
removed resource metadata from diff, we now consider evaluation: dyna…
comcalvi Dec 6, 2023
858abcc
Merge branch 'main' of github.com:aws/aws-cdk into comcalvi/changeset…
comcalvi Dec 6, 2023
edd8dc3
diff now evaluates fn::getAtt short form and long form equivalence
comcalvi Dec 6, 2023
7f5cec0
refactor
comcalvi Dec 6, 2023
f7f8d15
dependson is another problem...
comcalvi Dec 11, 2023
49e890c
tests, pt 1
comcalvi Dec 12, 2023
5129e95
cleanup
comcalvi Dec 12, 2023
4c66a1e
move to devDeps
comcalvi Dec 12, 2023
d373680
yarn.lock
comcalvi Dec 12, 2023
03a79f2
clean
comcalvi Dec 12, 2023
7c6287b
refactor
comcalvi Dec 13, 2023
da00e1a
CLI options
comcalvi Dec 13, 2023
212f603
remove metadata, but only when the flag is passed
comcalvi Dec 13, 2023
9d01051
not sure who uses this, but someone might...
comcalvi Dec 13, 2023
73c4685
console
comcalvi Dec 13, 2023
29d9094
conflicts
comcalvi Dec 13, 2023
3096aec
cleanup
comcalvi Dec 13, 2023
a106c7d
added the SDK comment
comcalvi Dec 13, 2023
e6e770d
style
comcalvi Dec 13, 2023
054e432
test comment cleanup
comcalvi Dec 13, 2023
561663d
tests
comcalvi Dec 13, 2023
5d44f9a
TS
comcalvi Dec 13, 2023
64e918b
bleh
comcalvi Dec 13, 2023
4686b37
cleanup
comcalvi Dec 14, 2023
d3edce4
lefotver
comcalvi Dec 14, 2023
76d9621
resource to import/...
comcalvi Dec 14, 2023
645feb2
more resources to import
comcalvi Dec 14, 2023
f90ba59
naming
comcalvi Dec 14, 2023
d186cc9
rework
comcalvi Dec 16, 2023
a81a5dd
testing
comcalvi Dec 16, 2023
52f86a1
error handling
comcalvi Dec 16, 2023
7d0d38f
dependsOn
comcalvi Dec 16, 2023
dec2261
rename
comcalvi Dec 16, 2023
d1b8533
nested stacks
comcalvi Dec 20, 2023
c9341ea
depends on improvements + comments
comcalvi Dec 20, 2023
ca152bb
1,1
comcalvi Dec 20, 2023
abfe661
done
comcalvi Dec 20, 2023
44041b1
unit test
comcalvi Dec 22, 2023
7b4d7e1
rename
comcalvi Jan 2, 2024
5c654da
cloudformation wowww
comcalvi Jan 3, 2024
3bd3b03
disable it, just for testing...
comcalvi Jan 3, 2024
0956232
Merge branch 'main' of github.com:aws/aws-cdk into comcalvi/changeset…
comcalvi Jan 3, 2024
b07e9ac
Merge branch 'main' of github.com:aws/aws-cdk into comcalvi/changeset…
comcalvi Jan 3, 2024
f0984a6
removing this fix, for now...
comcalvi Jan 3, 2024
c264294
comments
comcalvi Jan 3, 2024
7ebc5ce
nested stack test
comcalvi Jan 3, 2024
b35e538
idk what this is doing here...
comcalvi Jan 3, 2024
4f12072
Merge branch 'main' into comcalvi/changeset-diff
mergify[bot] Jan 4, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 46 additions & 16 deletions packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { CloudFormation } from 'aws-sdk';
import * as impl from './diff';
import * as types from './diff/types';
import { deepEqual, diffKeyedEntities, unionOf } from './diff/util';
import { ChangeSetReplacement, ResourceReplacements } from './format';

export * from './diff/types';

type DiffHandler = (diff: types.ITemplateDiff, oldValue: any, newValue: any) => void;
type DiffHandler = (diff: types.ITemplateDiff, oldValue: any, newValue: any, replacement?: ResourceReplacements) => void;
type HandlerRegistry = { [section: string]: DiffHandler };

const DIFF_HANDLERS: HandlerRegistry = {
Expand All @@ -22,8 +24,8 @@ const DIFF_HANDLERS: HandlerRegistry = {
diff.conditions = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffCondition)),
Transform: (diff, oldValue, newValue) =>
diff.transform = impl.diffAttribute(oldValue, newValue),
Resources: (diff, oldValue, newValue) =>
diff.resources = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffResource)),
Resources: (diff, oldValue, newValue, replacements?: ResourceReplacements) =>
diff.resources = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffResource, replacements)),
Outputs: (diff, oldValue, newValue) =>
diff.outputs = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffOutput)),
};
Expand All @@ -38,17 +40,22 @@ const DIFF_HANDLERS: HandlerRegistry = {
* a stack which current state is described by +currentTemplate+ is updated with
* the template +newTemplate+.
*/
export function diffTemplate(currentTemplate: { [key: string]: any }, newTemplate: { [key: string]: any }): types.TemplateDiff {
export function diffTemplate(
currentTemplate: { [key: string]: any },
newTemplate: { [key: string]: any },
changeSet?: CloudFormation.DescribeChangeSetOutput
): types.TemplateDiff {
const replacements = changeSet ? findResourceReplacements(changeSet): undefined;
// Base diff
const theDiff = calculateTemplateDiff(currentTemplate, newTemplate);
const theDiff = calculateTemplateDiff(currentTemplate, newTemplate, replacements);

// We're going to modify this in-place
const newTemplateCopy = deepCopy(newTemplate);

let didPropagateReferenceChanges;
let diffWithReplacements;
do {
diffWithReplacements = calculateTemplateDiff(currentTemplate, newTemplateCopy);
diffWithReplacements = calculateTemplateDiff(currentTemplate, newTemplateCopy, replacements);

// Propagate replacements for replaced resources
didPropagateReferenceChanges = false;
Expand Down Expand Up @@ -93,7 +100,11 @@ function propagatePropertyReplacement(source: types.ResourceDifference, dest: ty
}
}

function calculateTemplateDiff(currentTemplate: { [key: string]: any }, newTemplate: { [key: string]: any }): types.TemplateDiff {
function calculateTemplateDiff(
currentTemplate: { [key: string]: any },
newTemplate: { [key: string]: any },
replacements?: ResourceReplacements,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if we can avoid passing this structure all the way down through a bunch of functions.

For example:

  • Either stick all of these on a class, so it becomes a class member.
  • Or: organize the diffing more into a pipeline. First we calculate all the diffs that derive from the template change itself, and then using the information from change set we remove false positives from the diff. So it would be like:
function fullDiff(a, b, changeSet) {
  return filterFalsePositivies(changeSet, templateDiff(a,b));
}

It might be that that is not possible (tell me if it isn't). But I like keeping concerns separated as much as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The second approach was much easier than my first approach 😅

): types.TemplateDiff {
const differences: types.ITemplateDiff = {};
const unknown: { [key: string]: types.Difference<any> } = {};
for (const key of unionOf(Object.keys(currentTemplate), Object.keys(newTemplate)).sort()) {
Expand All @@ -104,8 +115,7 @@ function calculateTemplateDiff(currentTemplate: { [key: string]: any }, newTempl
}
const handler: DiffHandler = DIFF_HANDLERS[key]
|| ((_diff, oldV, newV) => unknown[key] = impl.diffUnknown(oldV, newV));
handler(differences, oldValue, newValue);

handler(differences, oldValue, newValue, replacements);
}
if (Object.keys(unknown).length > 0) {
differences.unknown = new types.DifferenceCollection(unknown);
Expand All @@ -114,13 +124,6 @@ function calculateTemplateDiff(currentTemplate: { [key: string]: any }, newTempl
return new types.TemplateDiff(differences);
}

/**
* Compare two CloudFormation resources and return semantic differences between them
*/
export function diffResource(oldValue: types.Resource, newValue: types.Resource): types.ResourceDifference {
return impl.diffResource(oldValue, newValue);
}

/**
* Replace all references to the given logicalID on the given template, in-place
*
Expand Down Expand Up @@ -184,3 +187,30 @@ function deepCopy(x: any): any {

return x;
}

function findResourceReplacements(changeSet: CloudFormation.DescribeChangeSetOutput): ResourceReplacements {
const replacements: ResourceReplacements = {};
for (const resourceChange of changeSet.Changes ?? []) {
const propertiesReplaced: { [propName: string]: ChangeSetReplacement } = {};
for (const propertyChange of resourceChange.ResourceChange?.Details ?? []) {
if (propertyChange.Target?.Attribute === 'Properties') {
const requiresReplacement = propertyChange.Target.RequiresRecreation === 'Always';
if (requiresReplacement && propertyChange.Evaluation === 'Static') {
propertiesReplaced[propertyChange.Target.Name!] = 'Always';
} else if (requiresReplacement && propertyChange.Evaluation === 'Dynamic') {
// If Evaluation is 'Dynamic', then this may cause replacement, or it may not.
// see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html
propertiesReplaced[propertyChange.Target.Name!] = 'Conditionally';
} else {
propertiesReplaced[propertyChange.Target.Name!] = propertyChange.Target.RequiresRecreation as ChangeSetReplacement;
}
}
}
replacements[resourceChange.ResourceChange?.LogicalResourceId ?? ''] = {
resourceReplaced: resourceChange.ResourceChange?.Replacement === 'True',
propertiesReplaced,
};
}

return replacements;
}
50 changes: 40 additions & 10 deletions packages/@aws-cdk/cloudformation-diff/lib/diff/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Resource } from '@aws-cdk/service-spec-types';
import * as types from './types';
import { deepEqual, diffKeyedEntities, loadResourceModel } from './util';
import { ResourceReplacement } from '../format';

export function diffAttribute(oldValue: any, newValue: any): types.Difference<string> {
return new types.Difference<string>(_asString(oldValue), _asString(newValue));
Expand All @@ -26,7 +27,12 @@ export function diffParameter(oldValue: types.Parameter, newValue: types.Paramet
return new types.ParameterDifference(oldValue, newValue);
}

export function diffResource(oldValue?: types.Resource, newValue?: types.Resource): types.ResourceDifference {
export function diffResource(
oldValue?: types.Resource,
newValue?: types.Resource,
_key?: string,
replacement?: ResourceReplacement,
): types.ResourceDifference {
const resourceType = {
oldType: oldValue && oldValue.Type,
newType: newValue && newValue.Type,
Expand All @@ -39,35 +45,59 @@ export function diffResource(oldValue?: types.Resource, newValue?: types.Resourc
const impl = loadResourceModel(resourceType.oldType);
propertyDiffs = diffKeyedEntities(oldValue!.Properties,
newValue!.Properties,
(oldVal, newVal, key) => _diffProperty(oldVal, newVal, key, impl));
(oldVal, newVal, key) => _diffProperty(oldVal, newVal, key, impl, replacement));

otherDiffs = diffKeyedEntities(oldValue, newValue, _diffOther);
delete otherDiffs.Properties;
// TODO: should only happen with the right flag set
delete otherDiffs.Metadata;
}

return new types.ResourceDifference(oldValue, newValue, {
resourceType, propertyDiffs, otherDiffs,
});

function _diffProperty(oldV: any, newV: any, key: string, resourceSpec?: Resource) {
let changeImpact = types.ResourceImpact.NO_CHANGE;
function _diffProperty(oldV: any, newV: any, key: string, resourceSpec?: Resource, changeSetReplacement?: ResourceReplacement) {
let changeImpact: types.ResourceImpact = types.ResourceImpact.NO_CHANGE;
if (changeSetReplacement === undefined) {
changeImpact = _resourceSpecImpact(oldV, newV, key, resourceSpec);
} else {
if (!(key in changeSetReplacement.propertiesReplaced)) {
// The changeset does not contain this property, which means it is not going to be updated. Hide this cosmetic template-only change from the diff
return new types.PropertyDifference(1, 1, { changeImpact });
}

switch (changeSetReplacement.propertiesReplaced[key]) {
case 'Always':
changeImpact = types.ResourceImpact.WILL_REPLACE;
break;
case 'Conditionally':
changeImpact = types.ResourceImpact.MAY_REPLACE;
break;
case 'Never':
changeImpact = types.ResourceImpact.WILL_UPDATE;
break;
}
}

return new types.PropertyDifference(oldV, newV, { changeImpact });
}

function _resourceSpecImpact(oldV: any, newV: any, key: string, resourceSpec?: Resource) {
const spec = resourceSpec?.properties?.[key];
if (spec && !deepEqual(oldV, newV)) {
switch (spec.causesReplacement) {
case 'yes':
changeImpact = types.ResourceImpact.WILL_REPLACE;
break;
return types.ResourceImpact.WILL_REPLACE;
case 'maybe':
changeImpact = types.ResourceImpact.MAY_REPLACE;
break;
return types.ResourceImpact.MAY_REPLACE;
default:
// In those cases, whatever is the current value is what we should keep
changeImpact = types.ResourceImpact.WILL_UPDATE;
return types.ResourceImpact.WILL_UPDATE;
}
}

return new types.PropertyDifference(oldV, newV, { changeImpact });
return types.ResourceImpact.NO_CHANGE;
}

function _diffOther(oldV: any, newV: any) {
Expand Down
71 changes: 69 additions & 2 deletions packages/@aws-cdk/cloudformation-diff/lib/diff/util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { loadAwsServiceSpecSync } from '@aws-cdk/aws-service-spec';
import { Resource, SpecDatabase } from '@aws-cdk/service-spec-types';
import { ResourceReplacement, ResourceReplacements } from '../format';

/**
* Compares two objects for equality, deeply. The function handles arguments that are
Expand All @@ -24,6 +25,12 @@ export function deepEqual(lvalue: any, rvalue: any): boolean {
lvalue.toString() === rvalue.toString()) {
return true;
}

if (containsIntrinsic(lvalue) && containsIntrinsic(rvalue)) {
if (yamlIntrinsicEqual(lvalue, rvalue)) {
return true;
}
}
// allows a numeric 10 and a literal "10" to be equivalent;
// this is consistent with CloudFormation.
if ((typeof lvalue === 'string' || typeof rvalue === 'string') &&
Expand Down Expand Up @@ -99,6 +106,65 @@ function dependsOnEqual(lvalue: any, rvalue: any): boolean {
return false;
}

function containsIntrinsic(value: any): boolean {
return Object.keys(value ?? {}).filter((key => key ? (key.includes('Fn::') ? true : false) : false)).length > 0;
}

/**
* Checks if a value has changed intrinsic form.
* Only applicable to CDK Migrate.
* For example, if a user has created a yaml template that uses
*
* !Fn::GetAtt 'foo.bar', CDK Migrate converts this to
* Fn::GetAtt: ['foo', 'bar']
*
* Both forms are equivalent
*/
function yamlIntrinsicEqual(lvalue: any, rvalue: any): boolean {
for (const lintrinsic of Object.keys(lvalue)) {
for (const rintrinsic of Object.keys(rvalue)) {
if (lintrinsic !== rintrinsic) {
return false;
}
const intrinsic = lintrinsic;
switch (intrinsic) {
// checks for equivalency in both yaml forms of Fn::GetAtt.
// !Fn::GetAtt 'foo.bar' is equivalent to
// Fn::GetAtt: ['foo', 'bar']
case 'Fn::GetAtt':
let array = undefined;
let str = undefined;
if (Array.isArray(lvalue[intrinsic]) && !Array.isArray(rvalue[intrinsic]) && typeof rvalue[intrinsic] === 'string') {
array = lvalue[intrinsic];
str = rvalue[intrinsic];
} else if (!Array.isArray(lvalue[intrinsic]) && Array.isArray(rvalue[intrinsic]) && typeof lvalue[intrinsic] === 'string') {
array = rvalue[intrinsic];
str = lvalue[intrinsic];
}

// if one value is an array, and the other is a string, check for form equivalency
if (array && str) {
// check if array is a string[]
let strArr: boolean = true;
array.forEach((item: any) => {
if (typeof item !== 'string') {
strArr = false;
}
});

if (strArr && array.join('.') === str) {
return true;
}
}

return deepEqual(lvalue[intrinsic], rvalue[intrinsic]);
}
}
}

return false;
}

/**
* Produce the differences between two maps, as a map, using a specified diff function.
*
Expand All @@ -111,7 +177,8 @@ function dependsOnEqual(lvalue: any, rvalue: any): boolean {
export function diffKeyedEntities<T>(
oldValue: { [key: string]: any } | undefined,
newValue: { [key: string]: any } | undefined,
elementDiff: (oldElement: any, newElement: any, key: string) => T): { [name: string]: T } {
elementDiff: (oldElement: any, newElement: any, key: string, replacements?: ResourceReplacement) => T,
replacements?: ResourceReplacements): { [name: string]: T } {
const result: { [name: string]: T } = {};
for (const logicalId of unionOf(Object.keys(oldValue || {}), Object.keys(newValue || {}))) {
const oldElement = oldValue && oldValue[logicalId];
Expand All @@ -122,7 +189,7 @@ export function diffKeyedEntities<T>(
continue;
}

result[logicalId] = elementDiff(oldElement, newElement, logicalId);
result[logicalId] = elementDiff(oldElement, newElement, logicalId, replacements ? replacements[logicalId]: undefined);
}
return result;
}
Expand Down
12 changes: 12 additions & 0 deletions packages/@aws-cdk/cloudformation-diff/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ export interface FormatStream extends NodeJS.WritableStream {
columns?: number;
}

/**
* maps logical IDs to their need to be replaced
*/
export type ResourceReplacements = { [logicalId: string]: ResourceReplacement };

export interface ResourceReplacement {
resourceReplaced: boolean,
propertiesReplaced: { [propertyName: string]: ChangeSetReplacement };
}

export type ChangeSetReplacement = 'Always' | 'Never' | 'Conditionally';

/**
* Renders template differences to the process' console.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/@aws-cdk/cloudformation-diff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"diff": "^5.1.0",
"fast-deep-equal": "^3.1.3",
"string-width": "^4.2.3",
"table": "^6.8.1"
"table": "^6.8.1",
"aws-sdk": "2.1516.0"
},
"devDependencies": {
"@aws-cdk/cdk-build-tools": "0.0.0",
Expand Down
Loading