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(diff): properties from ChangeSet diff were ignored #30268

Closed
wants to merge 42 commits into from
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b6d2cda
in progress
bergjaak May 18, 2024
73e97bb
in progress
bergjaak May 18, 2024
cd751b3
Merge branch 'main' of github.com:aws/aws-cdk into bergjaak/ResourceD…
bergjaak May 18, 2024
c15b448
in progress
bergjaak May 18, 2024
48c694c
I am going to consider what happens if I just always replace resource…
bergjaak May 18, 2024
62338a6
get resource details from changeset
bergjaak May 19, 2024
7a74592
get resource details from changeset
bergjaak May 19, 2024
0eba0cc
get resource details from changeset
bergjaak May 19, 2024
2e259b2
update unit tests
bergjaak May 19, 2024
464b699
separate file for changeset tests
bergjaak May 19, 2024
f8593d4
integ test added and updated
bergjaak May 19, 2024
05956d6
more tests
bergjaak May 19, 2024
52129b0
clean
bergjaak May 19, 2024
2683ed7
tests are passing
bergjaak May 19, 2024
56e20d8
fully tested
bergjaak May 19, 2024
54a920e
fully tested
bergjaak May 19, 2024
416f4b6
make _maybeParse better
bergjaak May 19, 2024
c15f9ef
make _maybeParse better
bergjaak May 19, 2024
bd9eccf
rename describe
bergjaak May 20, 2024
3b986cb
clean
bergjaak May 20, 2024
fff90cc
fix build
bergjaak May 20, 2024
82c7a57
add test for formatting
bergjaak May 20, 2024
207c416
fix describe changeset
bergjaak May 20, 2024
0b29d2b
Merge branch 'main' of github.com:aws/aws-cdk into bergjaak/ResourceD…
bergjaak May 20, 2024
f12b22b
better
bergjaak May 20, 2024
3e38f56
add private and public
bergjaak May 20, 2024
9fcaadd
use enum and better UNKNOWN
bergjaak May 20, 2024
94cbbda
include property values
bergjaak May 20, 2024
f23ce3d
now use beforeContext and afterContext to see property changes
bergjaak May 21, 2024
61bbcce
integ test for security changes
bergjaak May 21, 2024
a06fc7b
fixing unit tests
bergjaak May 22, 2024
c87aaa9
Merge branch 'main' of github.com:aws/aws-cdk into bergjaak/ResourceD…
bergjaak May 22, 2024
6fcfafd
stuff
bergjaak May 22, 2024
84ce20b
handle regions where describeChangeSet.IncludePropertyValues is not a…
bergjaak May 23, 2024
e65abf7
clean up comments
bergjaak May 23, 2024
f221971
removed context backups
bergjaak May 23, 2024
213df93
ready
bergjaak May 24, 2024
357ff5e
ready
bergjaak May 24, 2024
ed49a27
Merge branch 'main' into bergjaak/ResourceDiff2
bergjaak May 24, 2024
3694e71
ready
bergjaak May 24, 2024
e1ff643
Merge branch 'main' of github.com:aws/aws-cdk into bergjaak/ResourceD…
bergjaak May 24, 2024
8af1b4e
Merge branch 'bergjaak/ResourceDiff2' of github.com:aws/aws-cdk into …
bergjaak May 24, 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
106 changes: 9 additions & 97 deletions packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// The SDK should not make network calls here
import type { DescribeChangeSetOutput as DescribeChangeSet } from '@aws-sdk/client-cloudformation';
import * as impl from './diff';
import { TemplateAndChangeSetDiffMerger } from './diff/template-and-changeset-diff-merger';
import * as types from './diff/types';
import { deepEqual, diffKeyedEntities, unionOf } from './diff/util';

Expand Down Expand Up @@ -55,8 +56,14 @@ export function fullDiff(
normalize(newTemplate);
const theDiff = diffTemplate(currentTemplate, newTemplate);
if (changeSet) {
filterFalsePositives(theDiff, changeSet);
addImportInformation(theDiff, changeSet);
const changeSetDiff = new TemplateAndChangeSetDiffMerger({
changeSet: changeSet,
currentTemplateResources: currentTemplate.Resources,
});
changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources);
changeSetDiff.addImportInformation(theDiff.resources);
// TODO: now that you've added change set resources to diff, you shuold recreate the iamChanges so that the
// security diff is more accurate
} else if (isImport) {
makeAllResourceChangesImports(theDiff);
}
Expand Down Expand Up @@ -143,13 +150,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 @@ -214,100 +214,12 @@ function deepCopy(x: any): any {
return x;
}

function addImportInformation(diff: types.TemplateDiff, changeSet: DescribeChangeSetOutput) {
const imports = findResourceImports(changeSet);
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
if (imports.includes(logicalId)) {
change.isImport = true;
}
});
}

function makeAllResourceChangesImports(diff: types.TemplateDiff) {
diff.resources.forEachDifference((_logicalId: string, change: types.ResourceDifference) => {
change.isImport = true;
});
}

function filterFalsePositives(diff: types.TemplateDiff, changeSet: DescribeChangeSetOutput) {
const replacements = findResourceReplacements(changeSet);
diff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
if (change.resourceType.includes('AWS::Serverless')) {
// CFN applies the SAM transform before creating the changeset, so the changeset contains no information about SAM resources
return;
}
change.forEachDifference((type: 'Property' | 'Other', name: string, value: types.Difference<any> | types.PropertyDifference<any>) => {
if (type === 'Property') {
if (!replacements[logicalId]) {
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.NO_CHANGE;
(value as types.PropertyDifference<any>).isDifferent = false;
return;
}
switch (replacements[logicalId].propertiesReplaced[name]) {
case 'Always':
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.WILL_REPLACE;
break;
case 'Never':
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.WILL_UPDATE;
break;
case 'Conditionally':
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.MAY_REPLACE;
break;
case undefined:
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.NO_CHANGE;
(value as types.PropertyDifference<any>).isDifferent = false;
break;
// otherwise, defer to the changeImpact from `diffTemplate`
}
} else if (type === 'Other') {
switch (name) {
case 'Metadata':
change.setOtherChange('Metadata', new types.Difference<string>(value.newValue, value.newValue));
break;
}
}
});
});
}

function findResourceImports(changeSet: DescribeChangeSetOutput): string[] {
const importedResourceLogicalIds = [];
for (const resourceChange of changeSet.Changes ?? []) {
if (resourceChange.ResourceChange?.Action === 'Import') {
importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId!);
}
}

return importedResourceLogicalIds;
}

function findResourceReplacements(changeSet: DescribeChangeSetOutput): types.ResourceReplacements {
const replacements: types.ResourceReplacements = {};
for (const resourceChange of changeSet.Changes ?? []) {
const propertiesReplaced: { [propName: string]: types.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 types.ChangeSetReplacement;
}
}
}
replacements[resourceChange.ResourceChange?.LogicalResourceId!] = {
resourceReplaced: resourceChange.ResourceChange?.Replacement === 'True',
propertiesReplaced,
};
}

return replacements;
}
Comment on lines -284 to -309
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this was refactored into multiple functions in the new class


function normalize(template: any) {
if (typeof template === 'object') {
for (const key of (Object.keys(template ?? {}))) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// The SDK is only used to reference `DescribeChangeSetOutput`, so the SDK is added as a devDependency.
// The SDK should not make network calls here
import type { DescribeChangeSetOutput as DescribeChangeSet, ResourceChangeDetail } from '@aws-sdk/client-cloudformation';
import { diffResource } from '.';
import * as types from '../diff/types';

export type DescribeChangeSetOutput = DescribeChangeSet;

/**
* The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff.
*/
export class TemplateAndChangeSetDiffMerger {
Copy link
Contributor

Choose a reason for hiding this comment

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

I love this. This is a great refactor!

// If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff.
private UNKNOWN_RESOURCE_TYPE = 'UNKNOWN';

changeSet: DescribeChangeSetOutput;
currentTemplateResources: {[logicalId: string]: any};
changeSetResources: types.ChangeSetResources;

constructor(
args: {
changeSet: DescribeChangeSetOutput;
currentTemplateResources: {[logicalId: string]: any};
},
) {
this.changeSet = args.changeSet;
this.currentTemplateResources = args.currentTemplateResources;
this.changeSetResources = this.inspectChangeSet(this.changeSet);
}

inspectChangeSet(changeSet: DescribeChangeSetOutput): types.ChangeSetResources {
const replacements: types.ChangeSetResources = {};
for (const resourceChange of changeSet.Changes ?? []) {
if (resourceChange.ResourceChange?.LogicalResourceId === undefined) {
continue;
}

const propertiesReplaced: types.ChangeSetProperties = {};
for (const propertyChange of resourceChange.ResourceChange.Details ?? []) {
if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) {
propertiesReplaced[propertyChange.Target.Name] = {
changeSetReplacementMode: this.determineChangeSetReplacementMode(propertyChange),
beforeValue: propertyChange.Target.BeforeValue,
afterValue: propertyChange.Target.AfterValue,
};
}
}

replacements[resourceChange.ResourceChange.LogicalResourceId] = {
resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True',
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we sure ResourceChange will always exist? I'm seeing that the old code always had resourceChange.ResourceChange?.<property>.

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess line 80 would be our check for this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah, I'm trying my best to be super paranoid in this PR about running into anything that's undefined... part of me wants to wrap the entire changeset operation in try-catch and just continue with template diff if the changeset operations fail... but that would probably not be good in that it would mask many issues.

resourceType: resourceChange.ResourceChange.ResourceType ?? this.UNKNOWN_RESOURCE_TYPE, // DescribeChanegSet doesn't promise to have the ResourceType...
properties: propertiesReplaced,
};
}

return replacements;
}

determineChangeSetReplacementMode(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode {
if (propertyChange.Target!.RequiresRecreation === 'Always') {
switch (propertyChange.Evaluation) {
case 'Static':
return 'Always';
case '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
return 'Conditionally';
}
}

return propertyChange.Target!.RequiresRecreation as types.ChangeSetReplacementMode;
}

/**
* Finds resource differences that are only visible in the changeset diff from CloudFormation (that is, we can't find this difference in the diff between 2 templates)
* and adds those missing differences to the templateDiff.
*
* - One case when this can happen is when a resource is added to the stack through the changeset.
* - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes.
*/
addChangeSetResourcesToDiff(resourceDiffs: types.DifferenceCollection<types.Resource, types.ResourceDifference>) {
for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) {
const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId));
if (resourceNotFoundInTemplateDiff) {
const resourceDiffFromChangeset = diffResource(
this.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'OLD_VALUES'),
this.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'NEW_VALUES'),
);
resourceDiffs.set(logicalId, resourceDiffFromChangeset);
}

const propertyChangesFromTemplate = resourceDiffs.get(logicalId).propertyUpdates;
for (const propertyName of Object.keys(this.changeSetResources[logicalId]?.properties ?? {})) {
if (propertyName in propertyChangesFromTemplate) {
// If the property is already marked to be updated, then we don't need to do anything.
continue;
}

// This property diff will be hydrated when enhanceChangeImpacts is called.
const emptyPropertyDiff = new types.PropertyDifference({}, {}, {});
emptyPropertyDiff.isDifferent = true;
resourceDiffs.get(logicalId).setPropertyChange(propertyName, emptyPropertyDiff);
}
}

this.enhanceChangeImpacts(resourceDiffs);
}

convertResourceFromChangesetToResourceForDiff(
resourceInfoFromChangeset: types.ChangeSetResource,
parseOldOrNewValues: 'OLD_VALUES' | 'NEW_VALUES',
): types.Resource {
const props: { [logicalId: string]: string | undefined } = {};
if (parseOldOrNewValues === 'NEW_VALUES') {
for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) {
props[propertyName] = value.afterValue;
}
} else {
for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) {
props[propertyName] = value.beforeValue;
}
}

return {
Type: resourceInfoFromChangeset.resourceType ?? this.UNKNOWN_RESOURCE_TYPE,
Properties: props,
};
}

enhanceChangeImpacts(resourceDiffs: types.DifferenceCollection<types.Resource, types.ResourceDifference>) {
resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
if ((!change.resourceTypeChanged) && change.resourceType?.includes('AWS::Serverless')) {
// CFN applies the SAM transform before creating the changeset, so the changeset contains no information about SAM resources
return;
}
change.forEachDifference((type: 'Property' | 'Other', name: string, value: types.Difference<any> | types.PropertyDifference<any>) => {
if (type === 'Property') {
if (!this.changeSetResources[logicalId]) {
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.NO_CHANGE;
(value as types.PropertyDifference<any>).isDifferent = false;
return;
}

const changeSetReplacementMode = (this.changeSetResources[logicalId]?.properties ?? {})[name]?.changeSetReplacementMode;
switch (changeSetReplacementMode) {
case 'Always':
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.WILL_REPLACE;
break;
case 'Never':
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.WILL_UPDATE;
break;
case 'Conditionally':
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.MAY_REPLACE;
break;
case undefined:
(value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.NO_CHANGE;
(value as types.PropertyDifference<any>).isDifferent = false;
break;
// otherwise, defer to the changeImpact from `diffTemplate`
}
} else if (type === 'Other') {
switch (name) {
case 'Metadata':
change.setOtherChange('Metadata', new types.Difference<string>(value.newValue, value.newValue));
break;
}
}
});
});
}

addImportInformation(resourceDiffs: types.DifferenceCollection<types.Resource, types.ResourceDifference>) {
Copy link
Contributor

Choose a reason for hiding this comment

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

public addImportInformation

const imports = this.findResourceImports();
resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => {
if (imports.includes(logicalId)) {
change.isImport = true;
}
});
}

findResourceImports(): string[] {
Copy link
Contributor

Choose a reason for hiding this comment

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

private findResourceImports

const importedResourceLogicalIds = [];
for (const resourceChange of this.changeSet.Changes ?? []) {
if (resourceChange.ResourceChange?.Action === 'Import') {
importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId!);
}
}

return importedResourceLogicalIds;
}
}
31 changes: 24 additions & 7 deletions packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,23 @@ import { SecurityGroupChanges } from '../network/security-group-changes';

export type PropertyMap = {[key: string]: any };

export type ResourceReplacements = { [logicalId: string]: ResourceReplacement };
export type ChangeSetResources = { [logicalId: string]: ChangeSetResource };

export interface ResourceReplacement {
resourceReplaced: boolean;
propertiesReplaced: { [propertyName: string]: ChangeSetReplacement };
export interface ChangeSetResource {
resourceWasReplaced: boolean;
resourceType: string | undefined;
properties: ChangeSetProperties | undefined;
}

export type ChangeSetReplacement = 'Always' | 'Never' | 'Conditionally';
export type ChangeSetProperties = {
[propertyName: string]: {
changeSetReplacementMode: ChangeSetReplacementMode | undefined;
beforeValue: string | undefined;
afterValue: string | undefined;
};
}

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

/** Semantic differences between two CloudFormation templates. */
export class TemplateDiff implements ITemplateDiff {
Expand Down Expand Up @@ -198,6 +207,10 @@ export class TemplateDiff implements ITemplateDiff {
}
}
} else {
if (!resourceChange.resourceType) {
continue;
}

const resourceModel = loadResourceModel(resourceChange.resourceType);
if (resourceModel && this.resourceIsScrutinizable(resourceModel, scrutinyTypes)) {
ret.push({
Expand Down Expand Up @@ -367,6 +380,10 @@ export class DifferenceCollection<V, T extends IDifference<V>> {
delete this.diffs[logicalId];
}

public set(logicalId: string, diff: T): void {
this.diffs[logicalId] = diff;
bergjaak marked this conversation as resolved.
Show resolved Hide resolved
}

public get logicalIds(): string[] {
return Object.keys(this.changes);
}
Expand Down Expand Up @@ -626,11 +643,11 @@ export class ResourceDifference implements IDifference<Resource> {
*
* If the resource type was changed, it's an error to call this.
*/
public get resourceType(): string {
public get resourceType(): string | undefined {
if (this.resourceTypeChanged) {
throw new Error('Cannot get .resourceType, because the type was changed');
}
return this.resourceTypes.oldType || this.resourceTypes.newType!;
return this.resourceTypes.oldType || this.resourceTypes.newType;
}

/**
Expand Down
Loading
Loading