Skip to content

Commit

Permalink
refactor(diff): make dedicated file and class for incorporating chang…
Browse files Browse the repository at this point in the history
…eset to templateDiff (#30332)

### Reason for this change

I am making this change as part of #30268, but implementing the bug fix in a satisfactory way is becoming much, much, much more difficult than I thought it would. As it's now possible to view the changed values before and after a changeset is applied by using the DescribeChangeSets api with IncludePropertyValues, but the API is difficult to use because of not being supported in all regions, not including StatusReason, and being unable to paginate. So, I want to make that fix in a separate PR, once this refactor change is done.

### Description of changes

* A ton of unit tests and moved changeset diff logic into a dedicated class and file.

### Description of how you validated changes

* Many unit tests, integration tests, and manual tests

### Checklist
- [x] My code adheres to the [CONTRIBUTING GUIDE](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) and [DESIGN GUIDELINES](https://github.com/aws/aws-cdk/blob/main/docs/DESIGN_GUIDELINES.md)

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
bergjaak authored May 28, 2024
1 parent f54a945 commit 526d4ad
Show file tree
Hide file tree
Showing 8 changed files with 1,563 additions and 715 deletions.
104 changes: 7 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,12 @@ export function fullDiff(
normalize(newTemplate);
const theDiff = diffTemplate(currentTemplate, newTemplate);
if (changeSet) {
filterFalsePositives(theDiff, changeSet);
addImportInformation(theDiff, changeSet);
// These methods mutate the state of theDiff, using the changeSet.
const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet });
theDiff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) =>
changeSetDiff.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, change),
);
changeSetDiff.addImportInformationFromChangeset(theDiff.resources);
} else if (isImport) {
makeAllResourceChangesImports(theDiff);
}
Expand Down Expand Up @@ -143,13 +148,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 +212,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;
}

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,157 @@
// 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 as RCD } from '@aws-sdk/client-cloudformation';
import * as types from '../diff/types';

export type DescribeChangeSetOutput = DescribeChangeSet;
type ChangeSetResourceChangeDetail = RCD;

interface TemplateAndChangeSetDiffMergerOptions {
/*
* Only specifiable for testing. Otherwise, this is the datastructure that the changeSet is converted into so
* that we only pay attention to the subset of changeSet properties that are relevant for computing the diff.
*
* @default - the changeSet is converted into this datastructure.
*/
readonly changeSetResources?: types.ChangeSetResources;
}

export interface TemplateAndChangeSetDiffMergerProps extends TemplateAndChangeSetDiffMergerOptions {
/*
* The changeset that will be read and merged into the template diff.
*/
readonly changeSet: DescribeChangeSetOutput;
}

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

public static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ReplacementModes {
if (propertyChange.Target?.RequiresRecreation === undefined) {
// We can't determine if the resource will be replaced or not. That's what conditionally means.
return 'Conditionally';
}

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.ReplacementModes;
}

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

public changeSet: DescribeChangeSetOutput | undefined;
public changeSetResources: types.ChangeSetResources;

constructor(props: TemplateAndChangeSetDiffMergerProps) {
this.changeSet = props.changeSet;
this.changeSetResources = props.changeSetResources ?? this.convertDescribeChangeSetOutputToChangeSetResources(this.changeSet);
}

/**
* Read resources from the changeSet, extracting information into ChangeSetResources.
*/
private convertDescribeChangeSetOutputToChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources {
const changeSetResources: types.ChangeSetResources = {};
for (const resourceChange of changeSet.Changes ?? []) {
if (resourceChange.ResourceChange?.LogicalResourceId === undefined) {
continue; // Being defensive, here.
}

const propertyReplacementModes: types.PropertyReplacementModeMap = {};
for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { // Details is only included if resourceChange.Action === 'Modify'
if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) {
propertyReplacementModes[propertyChange.Target.Name] = {
replacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange),
};
}
}

changeSetResources[resourceChange.ResourceChange.LogicalResourceId] = {
resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True',
resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChangeSet doesn't promise to have the ResourceType...
propertyReplacementModes: propertyReplacementModes,
};
}

return changeSetResources;
}

/**
* This is writing over the "ChangeImpact" that was computed from the template difference, and instead using the ChangeImpact that is included from the ChangeSet.
* Using the ChangeSet ChangeImpact is more accurate. The ChangeImpact tells us what the consequence is of changing the field. If changing the field causes resource
* replacement (e.g., changing the name of an IAM role requires deleting and replacing the role), then ChangeImpact is "Always".
*/
public overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId: string, change: types.ResourceDifference) {
// resourceType getter throws an error if resourceTypeChanged
if ((change.resourceTypeChanged === true) || 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 changingPropertyCausesResourceReplacement = (this.changeSetResources[logicalId].propertyReplacementModes ?? {})[name]?.replacementMode;
switch (changingPropertyCausesResourceReplacement) {
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 the template diff
}
} else if (type === 'Other') {
switch (name) {
case 'Metadata':
// we want to ignore metadata changes in the diff, so compare newValue against newValue.
change.setOtherChange('Metadata', new types.Difference<string>(value.newValue, value.newValue));
break;
}
}
});
}

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

public findResourceImports(): (string | undefined)[] {
const importedResourceLogicalIds = [];
for (const resourceChange of this.changeSet?.Changes ?? []) {
if (resourceChange.ResourceChange?.Action === 'Import') {
importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId);
}
}

return importedResourceLogicalIds;
}
}
37 changes: 30 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,33 @@ 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 };
/**
* @param beforeContext is the BeforeContext field from the ChangeSet.ResourceChange.BeforeContext. This is the part of the CloudFormation template
* that defines what the resource is before the change is applied; that is, BeforeContext is CloudFormationTemplate.Resources[LogicalId] before the ChangeSet is executed.
*
* @param afterContext same as beforeContext but for after the change is made; that is, AfterContext is CloudFormationTemplate.Resources[LogicalId] after the ChangeSet is executed.
*
* * Here is an example of what a beforeContext/afterContext looks like:
* '{"Properties":{"Value":"sdflkja","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdk/mySsmParameter/Resource"}}'
*/
export interface ChangeSetResource {
resourceWasReplaced: boolean;
resourceType: string | undefined;
propertyReplacementModes: PropertyReplacementModeMap | undefined;
}

export type ChangeSetReplacement = 'Always' | 'Never' | 'Conditionally';
export type PropertyReplacementModeMap = {
[propertyName: string]: {
replacementMode: ReplacementModes | undefined;
};
}

/**
* 'Always' means that changing the corresponding property will always cause a resource replacement. Never means never. Conditionally means maybe.
*/
export type ReplacementModes = 'Always' | 'Never' | 'Conditionally';

/** Semantic differences between two CloudFormation templates. */
export class TemplateDiff implements ITemplateDiff {
Expand Down Expand Up @@ -198,6 +217,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 @@ -626,11 +649,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
2 changes: 1 addition & 1 deletion packages/@aws-cdk/cloudformation-diff/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const UPDATE = chalk.yellow('[~]');
const REMOVAL = chalk.red('[-]');
const IMPORT = chalk.blue('[←]');

class Formatter {
export class Formatter {
constructor(
private readonly stream: FormatStream,
private readonly logicalToPathMap: { [logicalId: string]: string },
Expand Down
Loading

0 comments on commit 526d4ad

Please sign in to comment.