packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts (108 lines of code) (raw):
// 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;
}
}