|
| 1 | +// The SDK is only used to reference `DescribeChangeSetOutput`, so the SDK is added as a devDependency. |
| 2 | +// The SDK should not make network calls here |
| 3 | +import type { DescribeChangeSetOutput as DescribeChangeSet, ResourceChangeDetail as RCD } from '@aws-sdk/client-cloudformation'; |
| 4 | +import * as types from '../diff/types'; |
| 5 | + |
| 6 | +export type DescribeChangeSetOutput = DescribeChangeSet; |
| 7 | +type ChangeSetResourceChangeDetail = RCD; |
| 8 | + |
| 9 | +interface TemplateAndChangeSetDiffMergerOptions { |
| 10 | + /* |
| 11 | + * Only specifiable for testing. Otherwise, this is the datastructure that the changeSet is converted into so |
| 12 | + * that we only pay attention to the subset of changeSet properties that are relevant for computing the diff. |
| 13 | + * |
| 14 | + * @default - the changeSet is converted into this datastructure. |
| 15 | + */ |
| 16 | + readonly changeSetResources?: types.ChangeSetResources; |
| 17 | +} |
| 18 | + |
| 19 | +export interface TemplateAndChangeSetDiffMergerProps extends TemplateAndChangeSetDiffMergerOptions { |
| 20 | + /* |
| 21 | + * The changeset that will be read and merged into the template diff. |
| 22 | + */ |
| 23 | + readonly changeSet: DescribeChangeSetOutput; |
| 24 | +} |
| 25 | + |
| 26 | +/** |
| 27 | + * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. |
| 28 | + */ |
| 29 | +export class TemplateAndChangeSetDiffMerger { |
| 30 | + |
| 31 | + public static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ReplacementModes { |
| 32 | + if (propertyChange.Target?.RequiresRecreation === undefined) { |
| 33 | + // We can't determine if the resource will be replaced or not. That's what conditionally means. |
| 34 | + return 'Conditionally'; |
| 35 | + } |
| 36 | + |
| 37 | + if (propertyChange.Target.RequiresRecreation === 'Always') { |
| 38 | + switch (propertyChange.Evaluation) { |
| 39 | + case 'Static': |
| 40 | + return 'Always'; |
| 41 | + case 'Dynamic': |
| 42 | + // If Evaluation is 'Dynamic', then this may cause replacement, or it may not. |
| 43 | + // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html |
| 44 | + return 'Conditionally'; |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + return propertyChange.Target.RequiresRecreation as types.ReplacementModes; |
| 49 | + } |
| 50 | + |
| 51 | + // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. |
| 52 | + private static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN_RESOURCE_TYPE'; |
| 53 | + |
| 54 | + public changeSet: DescribeChangeSetOutput | undefined; |
| 55 | + public changeSetResources: types.ChangeSetResources; |
| 56 | + |
| 57 | + constructor(props: TemplateAndChangeSetDiffMergerProps) { |
| 58 | + this.changeSet = props.changeSet; |
| 59 | + this.changeSetResources = props.changeSetResources ?? this.convertDescribeChangeSetOutputToChangeSetResources(this.changeSet); |
| 60 | + } |
| 61 | + |
| 62 | + /** |
| 63 | + * Read resources from the changeSet, extracting information into ChangeSetResources. |
| 64 | + */ |
| 65 | + private convertDescribeChangeSetOutputToChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { |
| 66 | + const changeSetResources: types.ChangeSetResources = {}; |
| 67 | + for (const resourceChange of changeSet.Changes ?? []) { |
| 68 | + if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { |
| 69 | + continue; // Being defensive, here. |
| 70 | + } |
| 71 | + |
| 72 | + const propertyReplacementModes: types.PropertyReplacementModeMap = {}; |
| 73 | + for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { // Details is only included if resourceChange.Action === 'Modify' |
| 74 | + if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { |
| 75 | + propertyReplacementModes[propertyChange.Target.Name] = { |
| 76 | + replacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), |
| 77 | + }; |
| 78 | + } |
| 79 | + } |
| 80 | + |
| 81 | + changeSetResources[resourceChange.ResourceChange.LogicalResourceId] = { |
| 82 | + resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True', |
| 83 | + resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChangeSet doesn't promise to have the ResourceType... |
| 84 | + propertyReplacementModes: propertyReplacementModes, |
| 85 | + }; |
| 86 | + } |
| 87 | + |
| 88 | + return changeSetResources; |
| 89 | + } |
| 90 | + |
| 91 | + /** |
| 92 | + * This is writing over the "ChangeImpact" that was computed from the template difference, and instead using the ChangeImpact that is included from the ChangeSet. |
| 93 | + * 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 |
| 94 | + * replacement (e.g., changing the name of an IAM role requires deleting and replacing the role), then ChangeImpact is "Always". |
| 95 | + */ |
| 96 | + public overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId: string, change: types.ResourceDifference) { |
| 97 | + // resourceType getter throws an error if resourceTypeChanged |
| 98 | + if ((change.resourceTypeChanged === true) || change.resourceType?.includes('AWS::Serverless')) { |
| 99 | + // CFN applies the SAM transform before creating the changeset, so the changeset contains no information about SAM resources |
| 100 | + return; |
| 101 | + } |
| 102 | + change.forEachDifference((type: 'Property' | 'Other', name: string, value: types.Difference<any> | types.PropertyDifference<any>) => { |
| 103 | + if (type === 'Property') { |
| 104 | + if (!this.changeSetResources[logicalId]) { |
| 105 | + (value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.NO_CHANGE; |
| 106 | + (value as types.PropertyDifference<any>).isDifferent = false; |
| 107 | + return; |
| 108 | + } |
| 109 | + |
| 110 | + const changingPropertyCausesResourceReplacement = (this.changeSetResources[logicalId].propertyReplacementModes ?? {})[name]?.replacementMode; |
| 111 | + switch (changingPropertyCausesResourceReplacement) { |
| 112 | + case 'Always': |
| 113 | + (value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.WILL_REPLACE; |
| 114 | + break; |
| 115 | + case 'Never': |
| 116 | + (value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.WILL_UPDATE; |
| 117 | + break; |
| 118 | + case 'Conditionally': |
| 119 | + (value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.MAY_REPLACE; |
| 120 | + break; |
| 121 | + case undefined: |
| 122 | + (value as types.PropertyDifference<any>).changeImpact = types.ResourceImpact.NO_CHANGE; |
| 123 | + (value as types.PropertyDifference<any>).isDifferent = false; |
| 124 | + break; |
| 125 | + // otherwise, defer to the changeImpact from the template diff |
| 126 | + } |
| 127 | + } else if (type === 'Other') { |
| 128 | + switch (name) { |
| 129 | + case 'Metadata': |
| 130 | + // we want to ignore metadata changes in the diff, so compare newValue against newValue. |
| 131 | + change.setOtherChange('Metadata', new types.Difference<string>(value.newValue, value.newValue)); |
| 132 | + break; |
| 133 | + } |
| 134 | + } |
| 135 | + }); |
| 136 | + } |
| 137 | + |
| 138 | + public addImportInformationFromChangeset(resourceDiffs: types.DifferenceCollection<types.Resource, types.ResourceDifference>) { |
| 139 | + const imports = this.findResourceImports(); |
| 140 | + resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { |
| 141 | + if (imports.includes(logicalId)) { |
| 142 | + change.isImport = true; |
| 143 | + } |
| 144 | + }); |
| 145 | + } |
| 146 | + |
| 147 | + public findResourceImports(): (string | undefined)[] { |
| 148 | + const importedResourceLogicalIds = []; |
| 149 | + for (const resourceChange of this.changeSet?.Changes ?? []) { |
| 150 | + if (resourceChange.ResourceChange?.Action === 'Import') { |
| 151 | + importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId); |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + return importedResourceLogicalIds; |
| 156 | + } |
| 157 | +} |
0 commit comments