packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts (187 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 } 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';
export * from './diff/types';
export type DescribeChangeSetOutput = DescribeChangeSet;
type DiffHandler = (diff: types.ITemplateDiff, oldValue: any, newValue: any) => void;
type HandlerRegistry = { [section: string]: DiffHandler };
const DIFF_HANDLERS: HandlerRegistry = {
AWSTemplateFormatVersion: (diff, oldValue, newValue) =>
diff.awsTemplateFormatVersion = impl.diffAttribute(oldValue, newValue),
Description: (diff, oldValue, newValue) =>
diff.description = impl.diffAttribute(oldValue, newValue),
Metadata: (diff, oldValue, newValue) =>
diff.metadata = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffMetadata)),
Parameters: (diff, oldValue, newValue) =>
diff.parameters = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffParameter)),
Mappings: (diff, oldValue, newValue) =>
diff.mappings = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffMapping)),
Conditions: (diff, oldValue, newValue) =>
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)),
Outputs: (diff, oldValue, newValue) =>
diff.outputs = new types.DifferenceCollection(diffKeyedEntities(oldValue, newValue, impl.diffOutput)),
};
/**
* Compare two CloudFormation templates and return semantic differences between them.
*
* @param currentTemplate the current state of the stack.
* @param newTemplate the target state of the stack.
* @param changeSet the change set for this stack.
*
* @returns a +types.TemplateDiff+ object that represents the changes that will happen if
* a stack which current state is described by +currentTemplate+ is updated with
* the template +newTemplate+.
*/
export function fullDiff(
currentTemplate: { [key: string]: any },
newTemplate: { [key: string]: any },
changeSet?: DescribeChangeSetOutput,
isImport?: boolean,
): types.TemplateDiff {
normalize(currentTemplate);
normalize(newTemplate);
const theDiff = diffTemplate(currentTemplate, newTemplate);
if (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);
}
return theDiff;
}
export function diffTemplate(
currentTemplate: { [key: string]: any },
newTemplate: { [key: string]: any },
): types.TemplateDiff {
// Base diff
const theDiff = calculateTemplateDiff(currentTemplate, newTemplate);
// We're going to modify this in-place
const newTemplateCopy = deepCopy(newTemplate);
let didPropagateReferenceChanges;
let diffWithReplacements;
do {
diffWithReplacements = calculateTemplateDiff(currentTemplate, newTemplateCopy);
// Propagate replacements for replaced resources
didPropagateReferenceChanges = false;
if (diffWithReplacements.resources) {
diffWithReplacements.resources.forEachDifference((logicalId, change) => {
if (change.changeImpact === types.ResourceImpact.WILL_REPLACE) {
if (propagateReplacedReferences(newTemplateCopy, logicalId)) {
didPropagateReferenceChanges = true;
}
}
});
}
} while (didPropagateReferenceChanges);
// Copy "replaced" states from `diffWithReplacements` to `theDiff`.
diffWithReplacements.resources
.filter(r => isReplacement(r!.changeImpact))
.forEachDifference((logicalId, downstreamReplacement) => {
const resource = theDiff.resources.get(logicalId);
if (resource.changeImpact !== downstreamReplacement.changeImpact) {
propagatePropertyReplacement(downstreamReplacement, resource);
}
});
return theDiff;
}
function isReplacement(impact: types.ResourceImpact) {
return impact === types.ResourceImpact.MAY_REPLACE || impact === types.ResourceImpact.WILL_REPLACE;
}
/**
* For all properties in 'source' that have a "replacement" impact, propagate that impact to "dest"
*/
function propagatePropertyReplacement(source: types.ResourceDifference, dest: types.ResourceDifference) {
for (const [propertyName, diff] of Object.entries(source.propertyUpdates)) {
if (diff.changeImpact && isReplacement(diff.changeImpact)) {
// Use the propertydiff of source in target. The result of this happens to be clear enough.
dest.setPropertyChange(propertyName, diff);
}
}
}
function calculateTemplateDiff(currentTemplate: { [key: string]: any }, newTemplate: { [key: string]: any }): 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()) {
const oldValue = currentTemplate[key];
const newValue = newTemplate[key];
if (deepEqual(oldValue, newValue)) {
continue;
}
const handler: DiffHandler = DIFF_HANDLERS[key]
|| ((_diff, oldV, newV) => unknown[key] = impl.diffUnknown(oldV, newV));
handler(differences, oldValue, newValue);
}
if (Object.keys(unknown).length > 0) {
differences.unknown = new types.DifferenceCollection(unknown);
}
return new types.TemplateDiff(differences);
}
/**
* Replace all references to the given logicalID on the given template, in-place
*
* Returns true if any references were replaced.
*/
function propagateReplacedReferences(template: object, logicalId: string): boolean {
let ret = false;
function recurse(obj: any) {
if (Array.isArray(obj)) {
obj.forEach(recurse);
}
if (typeof obj === 'object' && obj !== null) {
if (!replaceReference(obj)) {
Object.values(obj).forEach(recurse);
}
}
}
function replaceReference(obj: any) {
const keys = Object.keys(obj);
if (keys.length !== 1) {
return false;
}
const key = keys[0];
if (key === 'Ref') {
if (obj.Ref === logicalId) {
obj.Ref = logicalId + ' (replaced)';
ret = true;
}
return true;
}
if (key.startsWith('Fn::')) {
if (Array.isArray(obj[key]) && obj[key].length > 0 && obj[key][0] === logicalId) {
obj[key][0] = logicalId + '(replaced)';
ret = true;
}
return true;
}
return false;
}
recurse(template);
return ret;
}
function deepCopy(x: any): any {
if (Array.isArray(x)) {
return x.map(deepCopy);
}
if (typeof x === 'object' && x !== null) {
const ret: any = {};
for (const key of Object.keys(x)) {
ret[key] = deepCopy(x[key]);
}
return ret;
}
return x;
}
function makeAllResourceChangesImports(diff: types.TemplateDiff) {
diff.resources.forEachDifference((_logicalId: string, change: types.ResourceDifference) => {
change.isImport = true;
});
}
function normalize(template: any) {
if (typeof template === 'object') {
for (const key of (Object.keys(template ?? {}))) {
if (key === 'Fn::GetAtt' && typeof template[key] === 'string') {
template[key] = template[key].split('.');
continue;
} else if (key === 'DependsOn') {
if (typeof template[key] === 'string') {
template[key] = [template[key]];
} else if (Array.isArray(template[key])) {
template[key] = template[key].sort();
}
continue;
}
if (Array.isArray(template[key])) {
for (const element of (template[key])) {
normalize(element);
}
} else {
normalize(template[key]);
}
}
}
}