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