From b6d2cdad7e95830a6811730577035d30a1d9d563 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 15:40:30 -0400 Subject: [PATCH 01/36] in progress --- .../cloudformation-diff/lib/diff-template.ts | 129 ++---------------- .../cloudformation-diff/lib/diff/types.ts | 9 +- 2 files changed, 15 insertions(+), 123 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 025a707b62386..949e77804ed5a 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -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/changeset'; import * as types from './diff/types'; import { deepEqual, diffKeyedEntities, unionOf } from './diff/util'; @@ -55,8 +56,13 @@ export function fullDiff( normalize(newTemplate); const theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { - refineDiffWithChangeSet(theDiff, changeSet, newTemplate.Resources); - addImportInformation(theDiff, changeSet); + const changeSetDiff = new TemplateAndChangeSetDiffMerger({ + changeSet: changeSet, + currentTemplateResources: currentTemplate.Resources, + }); + changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); + changeSetDiff.enhanceChangeImpacts(theDiff.resources); + changeSetDiff.addImportInformation(theDiff.resources); } else if (isImport) { makeAllResourceChangesImports(theDiff); } @@ -207,131 +213,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 refineDiffWithChangeSet(diff: types.TemplateDiff, changeSet: DescribeChangeSetOutput, newTemplateResources: {[logicalId: string]: any}) { - const replacements = _findResourceReplacements(changeSet); - - _addChangeSetResourcesToDiff(replacements, newTemplateResources); - _enhanceChangeImpacts(replacements); - return; - - 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 _addChangeSetResourcesToDiff(_replacements: types.ResourceReplacements, _newTemplateResources: {[logicalId: string]: any}) { - const resourceDiffLogicalIds = diff.resources.logicalIds; - for (const logicalId of Object.keys(_replacements)) { - if (!(resourceDiffLogicalIds.includes(logicalId))) { - const noChangeResourceDiff = impl.diffResource(_newTemplateResources[logicalId], _newTemplateResources[logicalId]); - diff.resources.add(logicalId, noChangeResourceDiff); - } - - for (const propertyName of Object.keys(_replacements[logicalId].propertiesReplaced)) { - if (propertyName in diff.resources.get(logicalId).propertyUpdates) { - // If the property is already marked to be updated, then we don't need to do anything. - continue; - } - - const newProp = new types.PropertyDifference( - // these fields will be decided below - {}, {}, { changeImpact: undefined }, - ); - newProp.isDifferent = true; - diff.resources.get(logicalId).setPropertyChange(propertyName, newProp); - } - }; - } - - function _enhanceChangeImpacts(_replacements: types.ResourceReplacements) { - 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 | types.PropertyDifference) => { - if (type === 'Property') { - if (!_replacements[logicalId]) { - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; - (value as types.PropertyDifference).isDifferent = false; - return; - } - switch (_replacements[logicalId].propertiesReplaced[name]) { - case 'Always': - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; - break; - case 'Never': - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_UPDATE; - break; - case 'Conditionally': - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.MAY_REPLACE; - break; - case undefined: - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; - (value as types.PropertyDifference).isDifferent = false; - break; - // otherwise, defer to the changeImpact from `diffTemplate` - } - } else if (type === 'Other') { - switch (name) { - case 'Metadata': - change.setOtherChange('Metadata', new types.Difference(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 normalize(template: any) { if (typeof template === 'object') { for (const key of (Object.keys(template ?? {}))) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 9d66476c62852..fe15174872c4e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -10,6 +10,7 @@ export type ResourceReplacements = { [logicalId: string]: ResourceReplacement }; export interface ResourceReplacement { resourceReplaced: boolean; + resourceType: string; propertiesReplaced: { [propertyName: string]: ChangeSetReplacement }; } @@ -198,6 +199,10 @@ export class TemplateDiff implements ITemplateDiff { } } } else { + if (!resourceChange.resourceType) { + continue; + } + const resourceModel = loadResourceModel(resourceChange.resourceType); if (resourceModel && this.resourceIsScrutinizable(resourceModel, scrutinyTypes)) { ret.push({ @@ -630,11 +635,11 @@ export class ResourceDifference implements IDifference { * * 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; } /** From 73e97bbb074b4a2fd1dcafbbb6350bd5bc19e19e Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 15:42:30 -0400 Subject: [PATCH 02/36] in progress --- .../cloudformation-diff/lib/diff-template.ts | 2 +- .../template-and-changeset-diff-merger.ts | 183 ++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 949e77804ed5a..e16eb0234b7b9 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -2,7 +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/changeset'; +import { TemplateAndChangeSetDiffMerger } from './diff/template-and-changeset-diff-merger'; import * as types from './diff/types'; import { deepEqual, diffKeyedEntities, unionOf } from './diff/util'; diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts new file mode 100644 index 0000000000000..16f5481a459f6 --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -0,0 +1,183 @@ +// 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 types from '../diff/types'; + +export type DescribeChangeSetOutput = DescribeChangeSet; + +/** + * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. + */ +export class TemplateAndChangeSetDiffMerger { + changeSet: DescribeChangeSetOutput; + currentTemplateResources: {[logicalId: string]: any}; + replacements: types.ResourceReplacements; + + constructor( + args: { + changeSet: DescribeChangeSetOutput; + currentTemplateResources: {[logicalId: string]: any}; + }, + ) { + this.changeSet = args.changeSet; + this.currentTemplateResources = args.currentTemplateResources; + this.replacements = this.findResourceReplacements(this.changeSet); + } + + 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', + resourceType: resourceChange.ResourceChange?.ResourceType || 'UNKNOWN', // the changeset should always return the ResourceType... but just in case. + propertiesReplaced, + }; + } + + return _replacements; + } + + /** + * Finds resource differences that are only visible in the changeset diff from CloudFormation (that is, we can't find this difference in the diff between 2 templates) + * and adds those missing differences to the templateDiff. + * + * - One case when this can happen is when a resource is added to the stack through the changeset. + * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. + */ + addChangeSetResourcesToDiff(resourceDiffsFromTemplate: types.DifferenceCollection) { + for (const [logicalId, replacement] of Object.entries(this.replacements)) { + const resourceDiffFromChangeset = this.maybeCreateResourceTypeDiff({ + logicalIdsFromTemplateDiff: resourceDiffsFromTemplate.logicalIds, + logicalIdOfResourceFromChangeset: logicalId, + resourceTypeFromChangeset: replacement.resourceType, + resourceTypeFromTemplate: this.currentTemplateResources[logicalId]?.Type, + }); + + if (resourceDiffFromChangeset) { + resourceDiffsFromTemplate.add(logicalId, resourceDiffFromChangeset); + } + + for (const propertyName of Object.keys(this.replacements[logicalId].propertiesReplaced)) { + const propertyDiffFromChangeset = this.maybeCreatePropertyDiff({ + propertyNameFromChangeset: propertyName, + propertyUpdatesFromTemplateDiff: resourceDiffsFromTemplate.get(logicalId).propertyUpdates, + }); + + if (propertyDiffFromChangeset) { + resourceDiffsFromTemplate.get(logicalId).setPropertyChange(propertyName, propertyDiffFromChangeset); + } + } + } + } + + maybeCreatePropertyDiff(args: { + propertyNameFromChangeset: string; + propertyUpdatesFromTemplateDiff: {[key: string]: types.PropertyDifference}; + }): types.PropertyDifference | undefined { + if (args.propertyNameFromChangeset in args.propertyUpdatesFromTemplateDiff) { + // If the property is already marked to be updated, then we don't need to do anything. + return; + } + const newProp = new types.PropertyDifference({}, {}, { changeImpact: undefined }); + newProp.isDifferent = true; + return newProp; + } + + maybeCreateResourceTypeDiff(args: { + logicalIdsFromTemplateDiff: string[]; + logicalIdOfResourceFromChangeset: string; + resourceTypeFromChangeset: string | undefined; + resourceTypeFromTemplate: string | undefined; + }) { + const resourceNotFoundInTemplateDiff = !(args.logicalIdsFromTemplateDiff.includes(args.logicalIdOfResourceFromChangeset)); + if (resourceNotFoundInTemplateDiff) { + const noChangeResourceDiff = new types.ResourceDifference(undefined, undefined, { + resourceType: { + oldType: args.resourceTypeFromTemplate, + newType: args.resourceTypeFromChangeset, + }, + propertyDiffs: {}, + otherDiffs: {}, + }); + return noChangeResourceDiff; + } + + return undefined; + } + + enhanceChangeImpacts(resourceDiffsFromTemplate: types.DifferenceCollection) { + resourceDiffsFromTemplate.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 | types.PropertyDifference) => { + if (type === 'Property') { + if (!this.replacements[logicalId]) { + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; + (value as types.PropertyDifference).isDifferent = false; + return; + } + switch (this.replacements[logicalId].propertiesReplaced[name]) { + case 'Always': + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; + break; + case 'Never': + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_UPDATE; + break; + case 'Conditionally': + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.MAY_REPLACE; + break; + case undefined: + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; + (value as types.PropertyDifference).isDifferent = false; + break; + // otherwise, defer to the changeImpact from `diffTemplate` + } + } else if (type === 'Other') { + switch (name) { + case 'Metadata': + change.setOtherChange('Metadata', new types.Difference(value.newValue, value.newValue)); + break; + } + } + }); + }); + } + + addImportInformation(resourceDiffsFromTemplate: types.DifferenceCollection) { + const imports = this.findResourceImports(); + resourceDiffsFromTemplate.forEachDifference((logicalId: string, change: types.ResourceDifference) => { + if (imports.includes(logicalId)) { + change.isImport = true; + } + }); + } + + findResourceImports(): string[] { + const importedResourceLogicalIds = []; + for (const resourceChange of this.changeSet.Changes ?? []) { + if (resourceChange.ResourceChange?.Action === 'Import') { + importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId!); + } + } + + return importedResourceLogicalIds; + } +} From c15b4487adebe12510b91a7f9b22216dae9bdf33 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 16:30:45 -0400 Subject: [PATCH 03/36] in progress --- .../cli-lib-alpha/THIRD_PARTY_LICENSES | 8 ++-- .../cloudformation-diff/lib/diff-template.ts | 7 --- .../template-and-changeset-diff-merger.ts | 46 +++++++++++-------- .../cloudformation-diff/lib/diff/types.ts | 4 ++ packages/@aws-cdk/cx-api/FEATURE_FLAGS.md | 44 ++++++++++++++++-- 5 files changed, 76 insertions(+), 33 deletions(-) diff --git a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES index 2c9a5a75dad60..c037af426d656 100644 --- a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES +++ b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES @@ -207,7 +207,7 @@ The @aws-cdk/cli-lib-alpha package includes the following third-party software/l ---------------- -** @jsii/check-node@1.97.0 - https://www.npmjs.com/package/@jsii/check-node/v/1.97.0 | Apache-2.0 +** @jsii/check-node@1.98.0 - https://www.npmjs.com/package/@jsii/check-node/v/1.98.0 | Apache-2.0 jsii Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. @@ -266,7 +266,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------- -** ajv@8.12.0 - https://www.npmjs.com/package/ajv/v/8.12.0 | MIT +** ajv@8.13.0 - https://www.npmjs.com/package/ajv/v/8.13.0 | MIT The MIT License (MIT) Copyright (c) 2015-2021 Evgeny Poberezkin @@ -493,7 +493,7 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE ---------------- -** aws-sdk@2.1596.0 - https://www.npmjs.com/package/aws-sdk/v/2.1596.0 | Apache-2.0 +** aws-sdk@2.1610.0 - https://www.npmjs.com/package/aws-sdk/v/2.1610.0 | Apache-2.0 AWS SDK for JavaScript Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. @@ -691,7 +691,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ---------------- -** cdk-from-cfn@0.156.0 - https://www.npmjs.com/package/cdk-from-cfn/v/0.156.0 | MIT OR Apache-2.0 +** cdk-from-cfn@0.159.0 - https://www.npmjs.com/package/cdk-from-cfn/v/0.159.0 | MIT OR Apache-2.0 ---------------- diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 344917163f39a..e16eb0234b7b9 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -149,13 +149,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 * diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 16f5481a459f6..c4b12428992d8 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -1,6 +1,6 @@ // 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 type { DescribeChangeSetOutput as DescribeChangeSet, ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import * as types from '../diff/types'; export type DescribeChangeSetOutput = DescribeChangeSet; @@ -25,32 +25,42 @@ export class TemplateAndChangeSetDiffMerger { } findResourceReplacements(changeSet: DescribeChangeSetOutput): types.ResourceReplacements { - const _replacements: types.ResourceReplacements = {}; + const replacements: types.ResourceReplacements = {}; for (const resourceChange of changeSet.Changes ?? []) { + if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { + continue; + } + 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; - } + if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { + propertiesReplaced[propertyChange.Target.Name] = this.determineIfResourceIsReplaced(propertyChange); } } - _replacements[resourceChange.ResourceChange?.LogicalResourceId!] = { + replacements[resourceChange.ResourceChange?.LogicalResourceId] = { resourceReplaced: resourceChange.ResourceChange?.Replacement === 'True', - resourceType: resourceChange.ResourceChange?.ResourceType || 'UNKNOWN', // the changeset should always return the ResourceType... but just in case. - propertiesReplaced, + resourceType: resourceChange.ResourceChange?.ResourceType ?? 'UNKNOWN', // the changeset should always return the ResourceType... but just in case. + propertiesReplaced: propertiesReplaced, }; } - return _replacements; + return replacements; + } + + determineIfResourceIsReplaced(propertyChange: ResourceChangeDetail): types.ChangeSetReplacement { + 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.ChangeSetReplacement; } /** @@ -104,7 +114,7 @@ export class TemplateAndChangeSetDiffMerger { logicalIdOfResourceFromChangeset: string; resourceTypeFromChangeset: string | undefined; resourceTypeFromTemplate: string | undefined; - }) { + }): types.ResourceDifference | undefined { const resourceNotFoundInTemplateDiff = !(args.logicalIdsFromTemplateDiff.includes(args.logicalIdOfResourceFromChangeset)); if (resourceNotFoundInTemplateDiff) { const noChangeResourceDiff = new types.ResourceDifference(undefined, undefined, { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 456ed7fe6d73b..83194fc4f5b13 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -372,6 +372,10 @@ export class DifferenceCollection> { delete this.diffs[logicalId]; } + public add(logicalId: string, diff: T): void { + this.diffs[logicalId] = diff; + } + public get logicalIds(): string[] { return Object.keys(this.changes); } diff --git a/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md b/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md index 3678e750e3617..fd1a6d7bcb1f0 100644 --- a/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md +++ b/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md @@ -68,7 +68,9 @@ Flags come in three types: | [@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2](#aws-cdkaws-codepipelinedefaultpipelinetypetov2) | Enables Pipeline to set the default pipeline type to V2. | 2.133.0 | (default) | | [@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope](#aws-cdkaws-kmsreducecrossaccountregionpolicyscope) | When enabled, IAM Policy created from KMS key grant will reduce the resource scope to this key only. | 2.134.0 | (fix) | | [@aws-cdk/aws-eks:nodegroupNameAttribute](#aws-cdkaws-eksnodegroupnameattribute) | When enabled, nodegroupName attribute of the provisioned EKS NodeGroup will not have the cluster name prefix. | 2.139.0 | (fix) | -| [@aws-cdk/aws-ec2:ebsDefaultGp3Volume](#aws-cdkaws-ec2ebsdefaultgp3volume) | When enabled, the default volume type of the EBS volume will be GP3 | V2NEXT | (default) | +| [@aws-cdk/aws-ec2:ebsDefaultGp3Volume](#aws-cdkaws-ec2ebsdefaultgp3volume) | When enabled, the default volume type of the EBS volume will be GP3 | 2.140.0 | (default) | +| [@aws-cdk/pipelines:reduceAssetRoleTrustScope](#aws-cdkpipelinesreduceassetroletrustscope) | Remove the root account principal from PipelineAssetsFileRole trust policy | 2.141.0 | (default) | +| [@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm](#aws-cdkaws-ecsremovedefaultdeploymentalarm) | When enabled, remove default deployment alarm settings | V2NEXT | (default) | @@ -128,7 +130,8 @@ The following json shows the current recommended set of flags, as `cdk init` wou "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, "@aws-cdk/aws-eks:nodegroupNameAttribute": true, - "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true } } ``` @@ -171,6 +174,7 @@ are migrating a v1 CDK project to v2, explicitly set any of these flags which do | [@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId](#aws-cdkaws-apigatewayusageplankeyorderinsensitiveid) | Allow adding/removing multiple UsagePlanKeys independently | (fix) | 1.98.0 | `false` | `true` | | [@aws-cdk/aws-lambda:recognizeVersionProps](#aws-cdkaws-lambdarecognizeversionprops) | Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`. | (fix) | 1.106.0 | `false` | `true` | | [@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2\_2021](#aws-cdkaws-cloudfrontdefaultsecuritypolicytlsv12_2021) | Enable this feature flag to have cloudfront distributions use the security policy TLSv1.2_2021 by default. | (fix) | 1.117.0 | `false` | `true` | +| [@aws-cdk/pipelines:reduceAssetRoleTrustScope](#aws-cdkpipelinesreduceassetroletrustscope) | Remove the root account principal from PipelineAssetsFileRole trust policy | (default) | | `false` | `true` | @@ -185,7 +189,8 @@ Here is an example of a `cdk.json` file that restores v1 behavior for these flag "@aws-cdk/aws-rds:lowercaseDbIdentifier": false, "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": false, "@aws-cdk/aws-lambda:recognizeVersionProps": false, - "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": false + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": false, + "@aws-cdk/pipelines:reduceAssetRoleTrustScope": false } } ``` @@ -1293,9 +1298,40 @@ When this featuer flag is enabled, the default volume type of the EBS volume wil | Since | Default | Recommended | | ----- | ----- | ----- | | (not in v1) | | | -| V2NEXT | `false` | `true` | +| 2.140.0 | `false` | `true` | **Compatibility with old behavior:** Pass `volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD` to `Volume` construct to restore the previous behavior. +### @aws-cdk/pipelines:reduceAssetRoleTrustScope + +*Remove the root account principal from PipelineAssetsFileRole trust policy* (default) + +When this feature flag is enabled, the root account principal will not be added to the trust policy of asset role. +When this feature flag is disabled, it will keep the root account principal in the trust policy. + + +| Since | Default | Recommended | +| ----- | ----- | ----- | +| (not in v1) | | | +| 2.141.0 | `true` | `true` | + +**Compatibility with old behavior:** Disable the feature flag to add the root account principal back + + +### @aws-cdk/aws-ecs:removeDefaultDeploymentAlarm + +*When enabled, remove default deployment alarm settings* (default) + +When this featuer flag is enabled, remove the default deployment alarm settings when creating a AWS ECS service. + + +| Since | Default | Recommended | +| ----- | ----- | ----- | +| (not in v1) | | | +| V2NEXT | `false` | `true` | + +**Compatibility with old behavior:** Set AWS::ECS::Service 'DeploymentAlarms' manually to restore the previous behavior. + + From 48c694c1128e2f323b80ecf78bd7fd2304ec7a9e Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 18:21:03 -0400 Subject: [PATCH 04/36] I am going to consider what happens if I just always replace resources from the template with what's in the changeset --- .../cloudformation-diff/lib/diff-template.ts | 2 + .../template-and-changeset-diff-merger.ts | 114 ++++--- .../cloudformation-diff/lib/diff/types.ts | 20 +- .../test/diff-template.test.ts | 321 +++++++++++++++++- .../@aws-cdk/cloudformation-diff/test/util.ts | 31 ++ 5 files changed, 425 insertions(+), 63 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index e16eb0234b7b9..4c9375e3a000e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -63,6 +63,8 @@ export function fullDiff( changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); changeSetDiff.enhanceChangeImpacts(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); + // TODO: now that you've added change set resources to diff, you shuold recreate the iamChanges so that the + // security diff is more accurate } else if (isImport) { makeAllResourceChangesImports(theDiff); } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index c4b12428992d8..f58842da92ddd 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -1,6 +1,7 @@ // 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 } from '@aws-sdk/client-cloudformation'; +import { diffResource } from '.'; import * as types from '../diff/types'; export type DescribeChangeSetOutput = DescribeChangeSet; @@ -11,7 +12,7 @@ export type DescribeChangeSetOutput = DescribeChangeSet; export class TemplateAndChangeSetDiffMerger { changeSet: DescribeChangeSetOutput; currentTemplateResources: {[logicalId: string]: any}; - replacements: types.ResourceReplacements; + changeSetResources: types.ChangeSetResources; constructor( args: { @@ -21,34 +22,38 @@ export class TemplateAndChangeSetDiffMerger { ) { this.changeSet = args.changeSet; this.currentTemplateResources = args.currentTemplateResources; - this.replacements = this.findResourceReplacements(this.changeSet); + this.changeSetResources = this.findResourceReplacements(this.changeSet); } - findResourceReplacements(changeSet: DescribeChangeSetOutput): types.ResourceReplacements { - const replacements: types.ResourceReplacements = {}; + findResourceReplacements(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { + const replacements: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { continue; } - const propertiesReplaced: { [propName: string]: types.ChangeSetReplacement } = {}; - for (const propertyChange of resourceChange.ResourceChange?.Details ?? []) { + const propertiesReplaced: types.ChangeSetProperties = {}; + for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { - propertiesReplaced[propertyChange.Target.Name] = this.determineIfResourceIsReplaced(propertyChange); + propertiesReplaced[propertyChange.Target.Name] = { + changeSetReplacementMode: this.determineIfResourceIsReplaced(propertyChange), + beforeValue: propertyChange.Target.BeforeValue, + afterValue: propertyChange.Target.AfterValue, + }; } } - replacements[resourceChange.ResourceChange?.LogicalResourceId] = { - resourceReplaced: resourceChange.ResourceChange?.Replacement === 'True', - resourceType: resourceChange.ResourceChange?.ResourceType ?? 'UNKNOWN', // the changeset should always return the ResourceType... but just in case. - propertiesReplaced: propertiesReplaced, + replacements[resourceChange.ResourceChange.LogicalResourceId] = { + resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True', + resourceType: resourceChange.ResourceChange.ResourceType ?? 'UNKNOWN', // the changeset should always return the ResourceType... but just in case. + properties: propertiesReplaced, }; } return replacements; } - determineIfResourceIsReplaced(propertyChange: ResourceChangeDetail): types.ChangeSetReplacement { + determineIfResourceIsReplaced(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode { if (propertyChange.Target!.RequiresRecreation === 'Always') { switch (propertyChange.Evaluation) { case 'Static': @@ -60,7 +65,7 @@ export class TemplateAndChangeSetDiffMerger { } } - return propertyChange.Target!.RequiresRecreation as types.ChangeSetReplacement; + return propertyChange.Target!.RequiresRecreation as types.ChangeSetReplacementMode; } /** @@ -70,28 +75,26 @@ export class TemplateAndChangeSetDiffMerger { * - One case when this can happen is when a resource is added to the stack through the changeset. * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. */ - addChangeSetResourcesToDiff(resourceDiffsFromTemplate: types.DifferenceCollection) { - for (const [logicalId, replacement] of Object.entries(this.replacements)) { - const resourceDiffFromChangeset = this.maybeCreateResourceTypeDiff({ - logicalIdsFromTemplateDiff: resourceDiffsFromTemplate.logicalIds, - logicalIdOfResourceFromChangeset: logicalId, - resourceTypeFromChangeset: replacement.resourceType, - resourceTypeFromTemplate: this.currentTemplateResources[logicalId]?.Type, - }); - - if (resourceDiffFromChangeset) { - resourceDiffsFromTemplate.add(logicalId, resourceDiffFromChangeset); + addChangeSetResourcesToDiff(resourceDiffs: types.DifferenceCollection) { + for (const [logicalId, replacement] of Object.entries(this.changeSetResources)) { + const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); + if (resourceNotFoundInTemplateDiff) { + const resourceDiffFromChangeset = diffResource( + this.convertResourceFromChangesetToResourceForDiff(replacement, 'OLD_VALUES'), + this.convertResourceFromChangesetToResourceForDiff(replacement, 'NEW_VALUES'), + ); + resourceDiffs.set(logicalId, resourceDiffFromChangeset); } - for (const propertyName of Object.keys(this.replacements[logicalId].propertiesReplaced)) { - const propertyDiffFromChangeset = this.maybeCreatePropertyDiff({ - propertyNameFromChangeset: propertyName, - propertyUpdatesFromTemplateDiff: resourceDiffsFromTemplate.get(logicalId).propertyUpdates, - }); - - if (propertyDiffFromChangeset) { - resourceDiffsFromTemplate.get(logicalId).setPropertyChange(propertyName, propertyDiffFromChangeset); + const propertyChangesFromTemplate = resourceDiffs.get(logicalId).propertyUpdates; + for (const propertyName of Object.keys(this.changeSetResources[logicalId].properties)) { + if (propertyName in propertyChangesFromTemplate) { + // If the property is already marked to be updated, then we don't need to do anything. + return; } + const propertyDiffFromChangeset = new types.PropertyDifference({}, {}, { changeImpact: undefined }); + propertyDiffFromChangeset.isDifferent = true; + resourceDiffs.get(logicalId).setPropertyChange(propertyName, propertyDiffFromChangeset); } } } @@ -109,42 +112,41 @@ export class TemplateAndChangeSetDiffMerger { return newProp; } - maybeCreateResourceTypeDiff(args: { - logicalIdsFromTemplateDiff: string[]; - logicalIdOfResourceFromChangeset: string; - resourceTypeFromChangeset: string | undefined; - resourceTypeFromTemplate: string | undefined; - }): types.ResourceDifference | undefined { - const resourceNotFoundInTemplateDiff = !(args.logicalIdsFromTemplateDiff.includes(args.logicalIdOfResourceFromChangeset)); - if (resourceNotFoundInTemplateDiff) { - const noChangeResourceDiff = new types.ResourceDifference(undefined, undefined, { - resourceType: { - oldType: args.resourceTypeFromTemplate, - newType: args.resourceTypeFromChangeset, - }, - propertyDiffs: {}, - otherDiffs: {}, - }); - return noChangeResourceDiff; + convertResourceFromChangesetToResourceForDiff( + resourceInfoFromChangeset: types.ChangeSetResource, + parseOldOrNewValues: 'OLD_VALUES' | 'NEW_VALUES', + ): types.Resource { + const props: { [logicalId: string]: string | undefined } = {}; + if (parseOldOrNewValues === 'NEW_VALUES') { + for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties)) { + props[propertyName] = value.afterValue; + } + } else { + for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties)) { + props[propertyName] = value.beforeValue; + } } - return undefined; + return { + Type: resourceInfoFromChangeset.resourceType, + Properties: props, + }; } - enhanceChangeImpacts(resourceDiffsFromTemplate: types.DifferenceCollection) { - resourceDiffsFromTemplate.forEachDifference((logicalId: string, change: types.ResourceDifference) => { + enhanceChangeImpacts(resourceDiffs: types.DifferenceCollection) { + resourceDiffs.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 | types.PropertyDifference) => { if (type === 'Property') { - if (!this.replacements[logicalId]) { + if (!this.changeSetResources[logicalId]) { (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; (value as types.PropertyDifference).isDifferent = false; return; } - switch (this.replacements[logicalId].propertiesReplaced[name]) { + switch (this.changeSetResources[logicalId].properties[name]?.changeSetReplacementMode) { case 'Always': (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; break; @@ -171,9 +173,9 @@ export class TemplateAndChangeSetDiffMerger { }); } - addImportInformation(resourceDiffsFromTemplate: types.DifferenceCollection) { + addImportInformation(resourceDiffs: types.DifferenceCollection) { const imports = this.findResourceImports(); - resourceDiffsFromTemplate.forEachDifference((logicalId: string, change: types.ResourceDifference) => { + resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { if (imports.includes(logicalId)) { change.isImport = true; } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 83194fc4f5b13..f6dc32326b7e0 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -6,15 +6,23 @@ 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; +export interface ChangeSetResource { + resourceWasReplaced: boolean; resourceType: string; - propertiesReplaced: { [propertyName: string]: ChangeSetReplacement }; + properties: ChangeSetProperties; } -export type ChangeSetReplacement = 'Always' | 'Never' | 'Conditionally'; +export type ChangeSetProperties = { + [propertyName: string]: { + changeSetReplacementMode: ChangeSetReplacementMode; + beforeValue: string | undefined; + afterValue: string | undefined; + }; +} + +export type ChangeSetReplacementMode = 'Always' | 'Never' | 'Conditionally'; /** Semantic differences between two CloudFormation templates. */ export class TemplateDiff implements ITemplateDiff { @@ -372,7 +380,7 @@ export class DifferenceCollection> { delete this.diffs[logicalId]; } - public add(logicalId: string, diff: T): void { + public set(logicalId: string, diff: T): void { this.diffs[logicalId] = diff; } diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index 55b5a23753456..f715bc61ef55a 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -1,5 +1,6 @@ import * as fc from 'fast-check'; import { arbitraryTemplate } from './test-arbitraries'; +import { sqsQueue, sqsQueueWithAargs, ssmParam } from './util'; import { fullDiff, ResourceImpact } from '../lib/diff-template'; const POLICY_DOCUMENT = { foo: 'Bar' }; // Obviously a fake one! @@ -1231,4 +1232,322 @@ describe('changeset', () => { expect(differences.resources.differenceCount).toBe(1); expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); }); -}); \ No newline at end of file + test('properties that only show up in changeset diff are included in fullDiff', () => { + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + mySsmParameter: ssmParam, + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, + { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'mySsmParameter', + PhysicalResourceId: 'mySsmParameterFromStack', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: ['Properties'], + Details: [{ + Target: { Attribute: 'Properties', Name: 'Value', RequiresRecreation: 'Never' }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }], + }, + }, + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedVal', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(0); + expect(diffWithoutChangeSet.resources.changes).toEqual({}); + + expect(diffWithChangeSet.differenceCount).toBe(1); + const y = diffWithChangeSet.resources.changes; + console.log(y); + expect(diffWithChangeSet.resources.changes).toEqual( + { + mySsmParameter: { + oldValue: undefined, + newValue: undefined, + resourceTypes: { + oldType: 'AWS::SSM::Parameter', + newType: 'AWS::SSM::Parameter', + }, + propertyDiffs: { + Value: { + oldValue: { + }, + newValue: { + }, + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + }, + isAddition: true, + isRemoval: true, + isImport: undefined, + }, + }, + ); + }); + + test('resources that only show up in changeset diff are included in fullDiff', () => { + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + }, + }, + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, + { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/hiii', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: ['Properties'], + Details: [{ + Target: { Attribute: 'Properties', Name: 'QueueName', RequiresRecreation: 'Always' }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }], + }, + }, + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedVal', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(0); + expect(diffWithoutChangeSet.resources.changes).toEqual({}); + + expect(diffWithChangeSet.differenceCount).toBe(1); + expect(diffWithChangeSet.resources.changes).toEqual( + { + Queue: { + oldValue: sqsQueue, + newValue: sqsQueue, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: {}, + newValue: {}, + isDifferent: true, + changeImpact: 'WILL_REPLACE', // this is what changed! + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + }, + ); + }); + + test('a resource in the diff that is missing a property has the missing property added to the diff', () => { + // The idea is, we detect 1 change in the template diff -- and we detect another change in the changeset diff. + + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: sqsQueueWithAargs({ waitTime: 10 }), + }, + }; + + const newTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: sqsQueueWithAargs({ waitTime: 20 }), + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, + { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValueNEEEWWWEEERRRRR', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [{ + Target: { Attribute: 'Properties', Name: 'QueueName', RequiresRecreation: 'Always' }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + { + Target: { Attribute: 'Properties', Name: 'ReceiveMessageWaitTimeSeconds', RequiresRecreation: 'Never' }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }], + }, + }, + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedddd', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(1); + expect(diffWithoutChangeSet.resources.changes).toEqual( + { + Queue: { + oldValue: sqsQueueWithAargs({ waitTime: 10 }), + newValue: sqsQueueWithAargs({ waitTime: 20 }), + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + newValue: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: 10, + newValue: 20, + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + }, + ); + + expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed + expect(diffWithChangeSet.resources.changes).toEqual( + { + Queue: { + oldValue: sqsQueueWithAargs({ waitTime: 10 }), + newValue: sqsQueueWithAargs({ waitTime: 20 }), + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { + }, + newValue: { + }, + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: 10, + newValue: 20, + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + }, + ); + }); +}); diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index 08e4afc48eafb..e108d1c0bf845 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -72,4 +72,35 @@ export function largeSsoPermissionSet() { }, ), }); +} +export const ssmParam = { + Type: 'AWS::SSM::Parameter', + Properties: { + Name: 'mySsmParameterFromStack', + Type: 'String', + Value: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + }, +}; + +export const sqsQueue = { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + }, +}; + +export function sqsQueueWithAargs(args: { waitTime: number }) { + return { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + ReceiveMessageWaitTimeSeconds: args.waitTime, + }, + }; } \ No newline at end of file From 62338a6ae7eee0b6833d4d27f4cffc14cc85fbb4 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 20:30:11 -0400 Subject: [PATCH 05/36] get resource details from changeset --- .../cloudformation-diff/lib/diff-template.ts | 1 - .../template-and-changeset-diff-merger.ts | 58 ++++++------ .../cloudformation-diff/lib/diff/types.ts | 6 +- .../test/diff-template.test.ts | 88 +++++++++---------- .../aws-cdk/lib/api/util/cloudformation.ts | 3 +- 5 files changed, 74 insertions(+), 82 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 4c9375e3a000e..31b37b76956ab 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -61,7 +61,6 @@ export function fullDiff( currentTemplateResources: currentTemplate.Resources, }); changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); - changeSetDiff.enhanceChangeImpacts(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); // TODO: now that you've added change set resources to diff, you shuold recreate the iamChanges so that the // security diff is more accurate diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index f58842da92ddd..0efc2a8c2b738 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -10,6 +10,9 @@ export type DescribeChangeSetOutput = DescribeChangeSet; * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. */ export class TemplateAndChangeSetDiffMerger { + // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. + private UNKNOWN_RESOURCE_TYPE = 'UNKNOWN'; + changeSet: DescribeChangeSetOutput; currentTemplateResources: {[logicalId: string]: any}; changeSetResources: types.ChangeSetResources; @@ -22,10 +25,10 @@ export class TemplateAndChangeSetDiffMerger { ) { this.changeSet = args.changeSet; this.currentTemplateResources = args.currentTemplateResources; - this.changeSetResources = this.findResourceReplacements(this.changeSet); + this.changeSetResources = this.inspectChangeSet(this.changeSet); } - findResourceReplacements(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { + inspectChangeSet(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { const replacements: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { @@ -36,7 +39,7 @@ export class TemplateAndChangeSetDiffMerger { for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { propertiesReplaced[propertyChange.Target.Name] = { - changeSetReplacementMode: this.determineIfResourceIsReplaced(propertyChange), + changeSetReplacementMode: this.determineChangeSetReplacementMode(propertyChange), beforeValue: propertyChange.Target.BeforeValue, afterValue: propertyChange.Target.AfterValue, }; @@ -45,7 +48,7 @@ export class TemplateAndChangeSetDiffMerger { replacements[resourceChange.ResourceChange.LogicalResourceId] = { resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True', - resourceType: resourceChange.ResourceChange.ResourceType ?? 'UNKNOWN', // the changeset should always return the ResourceType... but just in case. + resourceType: resourceChange.ResourceChange.ResourceType ?? this.UNKNOWN_RESOURCE_TYPE, // DescribeChanegSet doesn't promise to have the ResourceType... properties: propertiesReplaced, }; } @@ -53,7 +56,7 @@ export class TemplateAndChangeSetDiffMerger { return replacements; } - determineIfResourceIsReplaced(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode { + determineChangeSetReplacementMode(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode { if (propertyChange.Target!.RequiresRecreation === 'Always') { switch (propertyChange.Evaluation) { case 'Static': @@ -76,40 +79,31 @@ export class TemplateAndChangeSetDiffMerger { * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. */ addChangeSetResourcesToDiff(resourceDiffs: types.DifferenceCollection) { - for (const [logicalId, replacement] of Object.entries(this.changeSetResources)) { + for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { const resourceDiffFromChangeset = diffResource( - this.convertResourceFromChangesetToResourceForDiff(replacement, 'OLD_VALUES'), - this.convertResourceFromChangesetToResourceForDiff(replacement, 'NEW_VALUES'), + this.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'OLD_VALUES'), + this.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'NEW_VALUES'), ); resourceDiffs.set(logicalId, resourceDiffFromChangeset); } const propertyChangesFromTemplate = resourceDiffs.get(logicalId).propertyUpdates; - for (const propertyName of Object.keys(this.changeSetResources[logicalId].properties)) { + for (const propertyName of Object.keys(this.changeSetResources[logicalId]?.properties ?? {})) { if (propertyName in propertyChangesFromTemplate) { // If the property is already marked to be updated, then we don't need to do anything. - return; + continue; } - const propertyDiffFromChangeset = new types.PropertyDifference({}, {}, { changeImpact: undefined }); - propertyDiffFromChangeset.isDifferent = true; - resourceDiffs.get(logicalId).setPropertyChange(propertyName, propertyDiffFromChangeset); + + // This property diff will be hydrated when enhanceChangeImpacts is called. + const emptyPropertyDiff = new types.PropertyDifference({}, {}, {}); + emptyPropertyDiff.isDifferent = true; + resourceDiffs.get(logicalId).setPropertyChange(propertyName, emptyPropertyDiff); } } - } - maybeCreatePropertyDiff(args: { - propertyNameFromChangeset: string; - propertyUpdatesFromTemplateDiff: {[key: string]: types.PropertyDifference}; - }): types.PropertyDifference | undefined { - if (args.propertyNameFromChangeset in args.propertyUpdatesFromTemplateDiff) { - // If the property is already marked to be updated, then we don't need to do anything. - return; - } - const newProp = new types.PropertyDifference({}, {}, { changeImpact: undefined }); - newProp.isDifferent = true; - return newProp; + this.enhanceChangeImpacts(resourceDiffs); } convertResourceFromChangesetToResourceForDiff( @@ -118,25 +112,25 @@ export class TemplateAndChangeSetDiffMerger { ): types.Resource { const props: { [logicalId: string]: string | undefined } = {}; if (parseOldOrNewValues === 'NEW_VALUES') { - for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties)) { + for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { props[propertyName] = value.afterValue; } } else { - for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties)) { + for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { props[propertyName] = value.beforeValue; } } return { - Type: resourceInfoFromChangeset.resourceType, + Type: resourceInfoFromChangeset.resourceType ?? this.UNKNOWN_RESOURCE_TYPE, Properties: props, }; } enhanceChangeImpacts(resourceDiffs: types.DifferenceCollection) { resourceDiffs.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 + if ((!change.resourceTypeChanged) && 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 | types.PropertyDifference) => { @@ -146,7 +140,9 @@ export class TemplateAndChangeSetDiffMerger { (value as types.PropertyDifference).isDifferent = false; return; } - switch (this.changeSetResources[logicalId].properties[name]?.changeSetReplacementMode) { + + const changeSetReplacementMode = (this.changeSetResources[logicalId]?.properties ?? {})[name]?.changeSetReplacementMode; + switch (changeSetReplacementMode) { case 'Always': (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; break; diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index f6dc32326b7e0..09a3795fb9b62 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -10,13 +10,13 @@ export type ChangeSetResources = { [logicalId: string]: ChangeSetResource }; export interface ChangeSetResource { resourceWasReplaced: boolean; - resourceType: string; - properties: ChangeSetProperties; + resourceType: string | undefined; + properties: ChangeSetProperties | undefined; } export type ChangeSetProperties = { [propertyName: string]: { - changeSetReplacementMode: ChangeSetReplacementMode; + changeSetReplacementMode: ChangeSetReplacementMode | undefined; beforeValue: string | undefined; afterValue: string | undefined; }; diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index f715bc61ef55a..2f53fd18ea5a6 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -1281,33 +1281,29 @@ describe('changeset', () => { expect(diffWithoutChangeSet.resources.changes).toEqual({}); expect(diffWithChangeSet.differenceCount).toBe(1); - const y = diffWithChangeSet.resources.changes; - console.log(y); - expect(diffWithChangeSet.resources.changes).toEqual( + expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( { - mySsmParameter: { - oldValue: undefined, - newValue: undefined, - resourceTypes: { - oldType: 'AWS::SSM::Parameter', - newType: 'AWS::SSM::Parameter', - }, - propertyDiffs: { - Value: { - oldValue: { - }, - newValue: { - }, - isDifferent: true, - changeImpact: 'WILL_UPDATE', + oldValue: undefined, + newValue: undefined, + resourceTypes: { + oldType: 'AWS::SSM::Parameter', + newType: 'AWS::SSM::Parameter', + }, + propertyDiffs: { + Value: { + oldValue: { }, + newValue: { + }, + isDifferent: true, + changeImpact: 'WILL_UPDATE', }, - otherDiffs: { - }, - isAddition: true, - isRemoval: true, - isImport: undefined, }, + otherDiffs: { + }, + isAddition: true, + isRemoval: true, + isImport: undefined, }, ); }); @@ -1369,34 +1365,34 @@ describe('changeset', () => { expect(diffWithoutChangeSet.resources.changes).toEqual({}); expect(diffWithChangeSet.differenceCount).toBe(1); - expect(diffWithChangeSet.resources.changes).toEqual( + expect(diffWithChangeSet.resources.changes.Queue).toEqual( { - Queue: { - oldValue: sqsQueue, - newValue: sqsQueue, - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: {}, - newValue: {}, - isDifferent: true, - changeImpact: 'WILL_REPLACE', // this is what changed! + oldValue: sqsQueue, + newValue: sqsQueue, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, + newValue: { }, + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, }, - isAddition: false, - isRemoval: false, - isImport: undefined, }, + isAddition: false, + isRemoval: false, + isImport: undefined, }, ); }); diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index 23e95f6d618e5..6d1f3140207b2 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -197,7 +197,7 @@ async function describeChangeSet( changeSetName: string, { fetchAll }: { fetchAll: boolean }, ): Promise { - const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise(); + const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true }).promise(); // If fetchAll is true, traverse all pages from the change set description. while (fetchAll && response.NextToken != null) { @@ -205,6 +205,7 @@ async function describeChangeSet( StackName: stackName, ChangeSetName: response.ChangeSetId ?? changeSetName, NextToken: response.NextToken, + IncludePropertyValues: true, }).promise(); // Consolidate the changes From 7a74592ed306f0ae31023e73248298a65d72a924 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 20:31:24 -0400 Subject: [PATCH 06/36] get resource details from changeset --- packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES index c037af426d656..2c9a5a75dad60 100644 --- a/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES +++ b/packages/@aws-cdk/cli-lib-alpha/THIRD_PARTY_LICENSES @@ -207,7 +207,7 @@ The @aws-cdk/cli-lib-alpha package includes the following third-party software/l ---------------- -** @jsii/check-node@1.98.0 - https://www.npmjs.com/package/@jsii/check-node/v/1.98.0 | Apache-2.0 +** @jsii/check-node@1.97.0 - https://www.npmjs.com/package/@jsii/check-node/v/1.97.0 | Apache-2.0 jsii Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. @@ -266,7 +266,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------- -** ajv@8.13.0 - https://www.npmjs.com/package/ajv/v/8.13.0 | MIT +** ajv@8.12.0 - https://www.npmjs.com/package/ajv/v/8.12.0 | MIT The MIT License (MIT) Copyright (c) 2015-2021 Evgeny Poberezkin @@ -493,7 +493,7 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE ---------------- -** aws-sdk@2.1610.0 - https://www.npmjs.com/package/aws-sdk/v/2.1610.0 | Apache-2.0 +** aws-sdk@2.1596.0 - https://www.npmjs.com/package/aws-sdk/v/2.1596.0 | Apache-2.0 AWS SDK for JavaScript Copyright 2012-2017 Amazon.com, Inc. or its affiliates. All Rights Reserved. @@ -691,7 +691,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI ---------------- -** cdk-from-cfn@0.159.0 - https://www.npmjs.com/package/cdk-from-cfn/v/0.159.0 | MIT OR Apache-2.0 +** cdk-from-cfn@0.156.0 - https://www.npmjs.com/package/cdk-from-cfn/v/0.156.0 | MIT OR Apache-2.0 ---------------- From 0eba0cc345320f392f071156f90553597e2cb482 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 20:32:39 -0400 Subject: [PATCH 07/36] get resource details from changeset --- packages/@aws-cdk/cx-api/FEATURE_FLAGS.md | 44 +++-------------------- 1 file changed, 4 insertions(+), 40 deletions(-) diff --git a/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md b/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md index fd1a6d7bcb1f0..3678e750e3617 100644 --- a/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md +++ b/packages/@aws-cdk/cx-api/FEATURE_FLAGS.md @@ -68,9 +68,7 @@ Flags come in three types: | [@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2](#aws-cdkaws-codepipelinedefaultpipelinetypetov2) | Enables Pipeline to set the default pipeline type to V2. | 2.133.0 | (default) | | [@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope](#aws-cdkaws-kmsreducecrossaccountregionpolicyscope) | When enabled, IAM Policy created from KMS key grant will reduce the resource scope to this key only. | 2.134.0 | (fix) | | [@aws-cdk/aws-eks:nodegroupNameAttribute](#aws-cdkaws-eksnodegroupnameattribute) | When enabled, nodegroupName attribute of the provisioned EKS NodeGroup will not have the cluster name prefix. | 2.139.0 | (fix) | -| [@aws-cdk/aws-ec2:ebsDefaultGp3Volume](#aws-cdkaws-ec2ebsdefaultgp3volume) | When enabled, the default volume type of the EBS volume will be GP3 | 2.140.0 | (default) | -| [@aws-cdk/pipelines:reduceAssetRoleTrustScope](#aws-cdkpipelinesreduceassetroletrustscope) | Remove the root account principal from PipelineAssetsFileRole trust policy | 2.141.0 | (default) | -| [@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm](#aws-cdkaws-ecsremovedefaultdeploymentalarm) | When enabled, remove default deployment alarm settings | V2NEXT | (default) | +| [@aws-cdk/aws-ec2:ebsDefaultGp3Volume](#aws-cdkaws-ec2ebsdefaultgp3volume) | When enabled, the default volume type of the EBS volume will be GP3 | V2NEXT | (default) | @@ -130,8 +128,7 @@ The following json shows the current recommended set of flags, as `cdk init` wou "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, "@aws-cdk/aws-eks:nodegroupNameAttribute": true, - "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, - "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true } } ``` @@ -174,7 +171,6 @@ are migrating a v1 CDK project to v2, explicitly set any of these flags which do | [@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId](#aws-cdkaws-apigatewayusageplankeyorderinsensitiveid) | Allow adding/removing multiple UsagePlanKeys independently | (fix) | 1.98.0 | `false` | `true` | | [@aws-cdk/aws-lambda:recognizeVersionProps](#aws-cdkaws-lambdarecognizeversionprops) | Enable this feature flag to opt in to the updated logical id calculation for Lambda Version created using the `fn.currentVersion`. | (fix) | 1.106.0 | `false` | `true` | | [@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2\_2021](#aws-cdkaws-cloudfrontdefaultsecuritypolicytlsv12_2021) | Enable this feature flag to have cloudfront distributions use the security policy TLSv1.2_2021 by default. | (fix) | 1.117.0 | `false` | `true` | -| [@aws-cdk/pipelines:reduceAssetRoleTrustScope](#aws-cdkpipelinesreduceassetroletrustscope) | Remove the root account principal from PipelineAssetsFileRole trust policy | (default) | | `false` | `true` | @@ -189,8 +185,7 @@ Here is an example of a `cdk.json` file that restores v1 behavior for these flag "@aws-cdk/aws-rds:lowercaseDbIdentifier": false, "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": false, "@aws-cdk/aws-lambda:recognizeVersionProps": false, - "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": false, - "@aws-cdk/pipelines:reduceAssetRoleTrustScope": false + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": false } } ``` @@ -1295,43 +1290,12 @@ any prefix. When this featuer flag is enabled, the default volume type of the EBS volume will be `EbsDeviceVolumeType.GENERAL_PURPOSE_SSD_GP3`. -| Since | Default | Recommended | -| ----- | ----- | ----- | -| (not in v1) | | | -| 2.140.0 | `false` | `true` | - -**Compatibility with old behavior:** Pass `volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD` to `Volume` construct to restore the previous behavior. - - -### @aws-cdk/pipelines:reduceAssetRoleTrustScope - -*Remove the root account principal from PipelineAssetsFileRole trust policy* (default) - -When this feature flag is enabled, the root account principal will not be added to the trust policy of asset role. -When this feature flag is disabled, it will keep the root account principal in the trust policy. - - -| Since | Default | Recommended | -| ----- | ----- | ----- | -| (not in v1) | | | -| 2.141.0 | `true` | `true` | - -**Compatibility with old behavior:** Disable the feature flag to add the root account principal back - - -### @aws-cdk/aws-ecs:removeDefaultDeploymentAlarm - -*When enabled, remove default deployment alarm settings* (default) - -When this featuer flag is enabled, remove the default deployment alarm settings when creating a AWS ECS service. - - | Since | Default | Recommended | | ----- | ----- | ----- | | (not in v1) | | | | V2NEXT | `false` | `true` | -**Compatibility with old behavior:** Set AWS::ECS::Service 'DeploymentAlarms' manually to restore the previous behavior. +**Compatibility with old behavior:** Pass `volumeType: EbsDeviceVolumeType.GENERAL_PURPOSE_SSD` to `Volume` construct to restore the previous behavior. From 2e259b22121f8a8431615c83fafbad6d97c2fe74 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 20:51:47 -0400 Subject: [PATCH 08/36] update unit tests --- .../test/diff-template.test.ts | 105 +++++++++++++----- .../@aws-cdk/cloudformation-diff/test/util.ts | 9 -- 2 files changed, 77 insertions(+), 37 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index 2f53fd18ea5a6..0ca15119a5020 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -1,6 +1,6 @@ import * as fc from 'fast-check'; import { arbitraryTemplate } from './test-arbitraries'; -import { sqsQueue, sqsQueueWithAargs, ssmParam } from './util'; +import { sqsQueueWithAargs, ssmParam } from './util'; import { fullDiff, ResourceImpact } from '../lib/diff-template'; const POLICY_DOCUMENT = { foo: 'Bar' }; // Obviously a fake one! @@ -1259,12 +1259,24 @@ describe('changeset', () => { PhysicalResourceId: 'mySsmParameterFromStack', ResourceType: 'AWS::SSM::Parameter', Replacement: 'False', - Scope: ['Properties'], - Details: [{ - Target: { Attribute: 'Properties', Name: 'Value', RequiresRecreation: 'Never' }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }], + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Value', + RequiresRecreation: 'Never', + Path: '/Properties/Value', + BeforeValue: 'changedddd', + AfterValue: 'sdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], }, }, ], @@ -1283,29 +1295,44 @@ describe('changeset', () => { expect(diffWithChangeSet.differenceCount).toBe(1); expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( { - oldValue: undefined, - newValue: undefined, + oldValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'changedddd', + }, + }, + newValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'sdflkja', + }, + }, resourceTypes: { oldType: 'AWS::SSM::Parameter', newType: 'AWS::SSM::Parameter', }, propertyDiffs: { Value: { - oldValue: { - }, - newValue: { - }, + oldValue: 'changedddd', + newValue: 'sdflkja', isDifferent: true, changeImpact: 'WILL_UPDATE', }, }, otherDiffs: { + Type: { + oldValue: 'AWS::SSM::Parameter', + newValue: 'AWS::SSM::Parameter', + isDifferent: false, + }, }, - isAddition: true, - isRemoval: true, + isAddition: false, + isRemoval: false, isImport: undefined, }, ); + + expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); }); test('resources that only show up in changeset diff are included in fullDiff', () => { @@ -1340,15 +1367,27 @@ describe('changeset', () => { PolicyAction: 'ReplaceAndDelete', Action: 'Modify', LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/hiii', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', ResourceType: 'AWS::SQS::Queue', Replacement: 'True', - Scope: ['Properties'], - Details: [{ - Target: { Attribute: 'Properties', Name: 'QueueName', RequiresRecreation: 'Always' }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }], + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'QueueName', + RequiresRecreation: 'Always', + Path: '/Properties/QueueName', + BeforeValue: 'newValuechangedddd', + AfterValue: 'newValuesdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], }, }, ], @@ -1367,18 +1406,26 @@ describe('changeset', () => { expect(diffWithChangeSet.differenceCount).toBe(1); expect(diffWithChangeSet.resources.changes.Queue).toEqual( { - oldValue: sqsQueue, - newValue: sqsQueue, + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuechangedddd', + }, + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuesdflkja', + }, + }, resourceTypes: { oldType: 'AWS::SQS::Queue', newType: 'AWS::SQS::Queue', }, propertyDiffs: { QueueName: { - oldValue: { - }, - newValue: { - }, + oldValue: 'newValuechangedddd', + newValue: 'newValuesdflkja', isDifferent: true, changeImpact: 'WILL_REPLACE', }, @@ -1395,6 +1442,8 @@ describe('changeset', () => { isImport: undefined, }, ); + + expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); }); test('a resource in the diff that is missing a property has the missing property added to the diff', () => { diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index e108d1c0bf845..2fae953f53a3d 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -84,15 +84,6 @@ export const ssmParam = { }, }; -export const sqsQueue = { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: { - Ref: 'SsmParameterValuetestbugreportC9', - }, - }, -}; - export function sqsQueueWithAargs(args: { waitTime: number }) { return { Type: 'AWS::SQS::Queue', From 464b699affa056084e97af5513cc061bbd2f6264 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 21:35:01 -0400 Subject: [PATCH 09/36] separate file for changeset tests --- .../cloudformation-diff/lib/diff-template.ts | 5 +- .../test/diff-template.test.ts | 973 ----------------- ...template-and-changeset-diff-merger.test.ts | 974 ++++++++++++++++++ 3 files changed, 976 insertions(+), 976 deletions(-) create mode 100644 packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 31b37b76956ab..c50dd057d446e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -54,7 +54,7 @@ export function fullDiff( normalize(currentTemplate); normalize(newTemplate); - const theDiff = diffTemplate(currentTemplate, newTemplate); + let theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet, @@ -62,8 +62,7 @@ export function fullDiff( }); changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); - // TODO: now that you've added change set resources to diff, you shuold recreate the iamChanges so that the - // security diff is more accurate + theDiff = new types.TemplateDiff(theDiff); // do this to propagate security changes. } else if (isImport) { makeAllResourceChangesImports(theDiff); } diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index 0ca15119a5020..78c68d474f5b8 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -1,6 +1,5 @@ import * as fc from 'fast-check'; import { arbitraryTemplate } from './test-arbitraries'; -import { sqsQueueWithAargs, ssmParam } from './util'; import { fullDiff, ResourceImpact } from '../lib/diff-template'; const POLICY_DOCUMENT = { foo: 'Bar' }; // Obviously a fake one! @@ -624,975 +623,3 @@ test('metadata changes are rendered in the diff', () => { differences = fullDiff(newTemplate, currentTemplate); expect(differences.resources.differenceCount).toBe(1); }); - -describe('changeset', () => { - test('changeset overrides spec replacements', () => { - // GIVEN - const currentTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'Name1' }, // Immutable prop - }, - }, - }; - const newTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: { Ref: 'BucketName' } }, // No change - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Parameters: [ - { - ParameterKey: 'BucketName', - ParameterValue: 'Name1', - }, - ], - Changes: [], - }); - - // THEN - expect(differences.differenceCount).toBe(0); - }); - - test('changeset does not overrides spec additions or deletions', () => { - // GIVEN - const currentTemplate = { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'MagicBucket' }, - }, - }, - }; - const newTemplate = { - Resources: { - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { QueueName: 'MagicQueue' }, - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - ResourceChange: { - Action: 'Remove', - LogicalResourceId: 'Bucket', - ResourceType: 'AWS::S3::Bucket', - Details: [], - }, - }, - { - ResourceChange: { - Action: 'Add', - LogicalResourceId: 'Queue', - ResourceType: 'AWS::SQS::Queue', - Details: [], - }, - }, - ], - }); - - // A realistic changeset will include Additions and Removals, but this shows that we don't use the changeset to determine additions or removals - const emptyChangeSetDifferences = fullDiff(currentTemplate, newTemplate, { - Changes: [], - }); - - // THEN - expect(differences.differenceCount).toBe(2); - expect(emptyChangeSetDifferences.differenceCount).toBe(2); - }); - - test('changeset replacements are respected', () => { - // GIVEN - const currentTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'Name1' }, // Immutable prop - }, - }, - }; - const newTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: { Ref: 'BucketName' } }, // 'Name1' -> 'Name2' - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Parameters: [ - { - ParameterKey: 'BucketName', - ParameterValue: 'Name2', - }, - ], - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'Bucket', - ResourceType: 'AWS::S3::Bucket', - Replacement: 'True', - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'BucketName', - RequiresRecreation: 'Always', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - }); - - // THEN - expect(differences.differenceCount).toBe(1); - }); - - // This is directly in-line with changeset behavior, - // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html - test('dynamic changeset replacements are considered conditional replacements', () => { - // GIVEN - const currentTemplate = { - Resources: { - Instance: { - Type: 'AWS::EC2::Instance', - Properties: { - ImageId: 'ami-79fd7eee', - KeyName: 'rsa-is-fun', - }, - }, - }, - }; - const newTemplate = { - Resources: { - Instance: { - Type: 'AWS::EC2::Instance', - Properties: { - ImageId: 'ami-79fd7eee', - KeyName: 'but-sha-is-cool', - }, - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'Instance', - ResourceType: 'AWS::EC2::Instance', - Replacement: 'Conditional', - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'KeyName', - RequiresRecreation: 'Always', - }, - Evaluation: 'Dynamic', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - }); - - // THEN - expect(differences.differenceCount).toBe(1); - expect(differences.resources.changes.Instance.changeImpact).toEqual(ResourceImpact.MAY_REPLACE); - expect(differences.resources.changes.Instance.propertyUpdates).toEqual({ - KeyName: { - changeImpact: ResourceImpact.MAY_REPLACE, - isDifferent: true, - oldValue: 'rsa-is-fun', - newValue: 'but-sha-is-cool', - }, - }); - }); - - test('changeset resource replacement is not tracked through references', () => { - // GIVEN - const currentTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'Name1' }, // Immutable prop - }, - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { QueueName: { Ref: 'Bucket' } }, // Immutable prop - }, - Topic: { - Type: 'AWS::SNS::Topic', - Properties: { TopicName: { Ref: 'Queue' } }, // Immutable prop - }, - }, - }; - - // WHEN - const newTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: { Ref: 'BucketName' } }, - }, - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { QueueName: { Ref: 'Bucket' } }, - }, - Topic: { - Type: 'AWS::SNS::Topic', - Properties: { TopicName: { Ref: 'Queue' } }, - }, - }, - }; - const differences = fullDiff(currentTemplate, newTemplate, { - Parameters: [ - { - ParameterKey: 'BucketName', - ParameterValue: 'Name1', - }, - ], - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'Bucket', - ResourceType: 'AWS::S3::Bucket', - Replacement: 'False', - Details: [], - }, - }, - ], - }); - - // THEN - expect(differences.resources.differenceCount).toBe(0); - }); - - test('Fn::GetAtt short form and long form are equivalent', () => { - // GIVEN - const currentTemplate = { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'BucketName' }, - }, - }, - Outputs: { - BucketArnOneWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, - BucketArnAnotherWay: { 'Fn::GetAtt': 'BucketName.Arn' }, - }, - }; - const newTemplate = { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'BucketName' }, - }, - }, - Outputs: { - BucketArnOneWay: { 'Fn::GetAtt': 'BucketName.Arn' }, - BucketArnAnotherWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate); - - // THEN - expect(differences.differenceCount).toBe(0); - }); - - test('metadata changes are obscured from the diff', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: 'magic-bucket', - Metadata: { - 'aws:cdk:path': '/foo/BucketResource', - }, - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: 'magic-bucket', - Metadata: { - 'aws:cdk:path': '/bar/BucketResource', - }, - }, - }, - }; - - // THEN - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.differenceCount).toBe(0); - }); - - test('single element arrays are equivalent to the single element in DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['SomeResource'], - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: 'SomeResource', - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - - differences = fullDiff(newTemplate, currentTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - }); - - test('array equivalence is independent of element order in DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['SomeResource', 'AnotherResource'], - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['AnotherResource', 'SomeResource'], - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - - differences = fullDiff(newTemplate, currentTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - }); - - test('arrays of different length are considered unequal in DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['SomeResource', 'AnotherResource', 'LastResource'], - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['AnotherResource', 'SomeResource'], - }, - }, - }; - - // dependsOn changes do not appear in the changeset - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.resources.differenceCount).toBe(1); - - differences = fullDiff(newTemplate, currentTemplate, {}); - expect(differences.resources.differenceCount).toBe(1); - }); - - test('arrays that differ only in element order are considered unequal outside of DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: { 'Fn::Select': [0, ['name1', 'name2']] }, - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: { 'Fn::Select': [0, ['name2', 'name1']] }, - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'BucketResource', - ResourceType: 'AWS::S3::Bucket', - Replacement: 'True', - Details: [{ - Evaluation: 'Static', - Target: { - Attribute: 'Properties', - Name: 'BucketName', - RequiresRecreation: 'Always', - }, - }], - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - }); - - test('SAM Resources are rendered with changeset diffs', () => { - // GIVEN - const currentTemplate = { - Resources: { - ServerlessFunction: { - Type: 'AWS::Serverless::Function', - Properties: { - CodeUri: 's3://bermuda-triangle-1337-bucket/old-handler.zip', - }, - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - ServerlessFunction: { - Type: 'AWS::Serverless::Function', - Properties: { - CodeUri: 's3://bermuda-triangle-1337-bucket/new-handler.zip', - }, - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'ServerlessFunction', - ResourceType: 'AWS::Lambda::Function', // The SAM transform is applied before the changeset is created, so the changeset has a Lambda resource here! - Replacement: 'False', - Details: [{ - Evaluation: 'Static', - Target: { - Attribute: 'Properties', - Name: 'Code', - RequiresRecreation: 'Never', - }, - }], - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - }); - - test('imports are respected for new stacks', async () => { - // GIVEN - const currentTemplate = {}; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Import', - LogicalResourceId: 'BucketResource', - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); - }); - - test('imports are respected for existing stacks', async () => { - // GIVEN - const currentTemplate = { - Resources: { - OldResource: { - Type: 'AWS::Something::Resource', - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - OldResource: { - Type: 'AWS::Something::Resource', - }, - BucketResource: { - Type: 'AWS::S3::Bucket', - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Import', - LogicalResourceId: 'BucketResource', - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); - }); - test('properties that only show up in changeset diff are included in fullDiff', () => { - // GIVEN - const currentTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - mySsmParameter: ssmParam, - }, - }; - - // WHEN - const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); - const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, - { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'mySsmParameter', - PhysicalResourceId: 'mySsmParameterFromStack', - ResourceType: 'AWS::SSM::Parameter', - Replacement: 'False', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'Value', - RequiresRecreation: 'Never', - Path: '/Properties/Value', - BeforeValue: 'changedddd', - AfterValue: 'sdflkja', - AttributeChangeType: 'Modify', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - Parameters: [{ - ParameterKey: 'SsmParameterValuetestbugreportC9', - ParameterValue: 'goodJob', - ResolvedValue: 'changedVal', - }], - }, - ); - - // THEN - expect(diffWithoutChangeSet.differenceCount).toBe(0); - expect(diffWithoutChangeSet.resources.changes).toEqual({}); - - expect(diffWithChangeSet.differenceCount).toBe(1); - expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( - { - oldValue: { - Type: 'AWS::SSM::Parameter', - Properties: { - Value: 'changedddd', - }, - }, - newValue: { - Type: 'AWS::SSM::Parameter', - Properties: { - Value: 'sdflkja', - }, - }, - resourceTypes: { - oldType: 'AWS::SSM::Parameter', - newType: 'AWS::SSM::Parameter', - }, - propertyDiffs: { - Value: { - oldValue: 'changedddd', - newValue: 'sdflkja', - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SSM::Parameter', - newValue: 'AWS::SSM::Parameter', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - ); - - expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); - }); - - test('resources that only show up in changeset diff are included in fullDiff', () => { - // GIVEN - const currentTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: { - Ref: 'SsmParameterValuetestbugreportC9', - }, - }, - }, - }, - }; - - // WHEN - const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); - const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, - { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - PolicyAction: 'ReplaceAndDelete', - Action: 'Modify', - LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', - ResourceType: 'AWS::SQS::Queue', - Replacement: 'True', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'QueueName', - RequiresRecreation: 'Always', - Path: '/Properties/QueueName', - BeforeValue: 'newValuechangedddd', - AfterValue: 'newValuesdflkja', - AttributeChangeType: 'Modify', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - Parameters: [{ - ParameterKey: 'SsmParameterValuetestbugreportC9', - ParameterValue: 'goodJob', - ResolvedValue: 'changedVal', - }], - }, - ); - - // THEN - expect(diffWithoutChangeSet.differenceCount).toBe(0); - expect(diffWithoutChangeSet.resources.changes).toEqual({}); - - expect(diffWithChangeSet.differenceCount).toBe(1); - expect(diffWithChangeSet.resources.changes.Queue).toEqual( - { - oldValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: 'newValuechangedddd', - }, - }, - newValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: 'newValuesdflkja', - }, - }, - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: 'newValuechangedddd', - newValue: 'newValuesdflkja', - isDifferent: true, - changeImpact: 'WILL_REPLACE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - ); - - expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); - }); - - test('a resource in the diff that is missing a property has the missing property added to the diff', () => { - // The idea is, we detect 1 change in the template diff -- and we detect another change in the changeset diff. - - // GIVEN - const currentTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - Queue: sqsQueueWithAargs({ waitTime: 10 }), - }, - }; - - const newTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - Queue: sqsQueueWithAargs({ waitTime: 20 }), - }, - }; - - // WHEN - const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); - const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, - { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - PolicyAction: 'ReplaceAndDelete', - Action: 'Modify', - LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValueNEEEWWWEEERRRRR', - ResourceType: 'AWS::SQS::Queue', - Replacement: 'True', - Scope: [ - 'Properties', - ], - Details: [{ - Target: { Attribute: 'Properties', Name: 'QueueName', RequiresRecreation: 'Always' }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - { - Target: { Attribute: 'Properties', Name: 'ReceiveMessageWaitTimeSeconds', RequiresRecreation: 'Never' }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }], - }, - }, - ], - Parameters: [{ - ParameterKey: 'SsmParameterValuetestbugreportC9', - ParameterValue: 'goodJob', - ResolvedValue: 'changedddd', - }], - }, - ); - - // THEN - expect(diffWithoutChangeSet.differenceCount).toBe(1); - expect(diffWithoutChangeSet.resources.changes).toEqual( - { - Queue: { - oldValue: sqsQueueWithAargs({ waitTime: 10 }), - newValue: sqsQueueWithAargs({ waitTime: 20 }), - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: { - Ref: 'SsmParameterValuetestbugreportC9', - }, - newValue: { - Ref: 'SsmParameterValuetestbugreportC9', - }, - isDifferent: false, - changeImpact: 'NO_CHANGE', - }, - ReceiveMessageWaitTimeSeconds: { - oldValue: 10, - newValue: 20, - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - }, - ); - - expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed - expect(diffWithChangeSet.resources.changes).toEqual( - { - Queue: { - oldValue: sqsQueueWithAargs({ waitTime: 10 }), - newValue: sqsQueueWithAargs({ waitTime: 20 }), - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: { - }, - newValue: { - }, - isDifferent: true, - changeImpact: 'WILL_REPLACE', - }, - ReceiveMessageWaitTimeSeconds: { - oldValue: 10, - newValue: 20, - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - }, - ); - }); -}); diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts new file mode 100644 index 0000000000000..8fdeb20b46d6e --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -0,0 +1,974 @@ +import { sqsQueueWithAargs, ssmParam } from './util'; +import { fullDiff, ResourceImpact } from '../lib/diff-template'; + +describe('changeset', () => { + test('changeset overrides spec replacements', () => { + // GIVEN + const currentTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'Name1' }, // Immutable prop + }, + }, + }; + const newTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'BucketName' } }, // No change + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Parameters: [ + { + ParameterKey: 'BucketName', + ParameterValue: 'Name1', + }, + ], + Changes: [], + }); + + // THEN + expect(differences.differenceCount).toBe(0); + }); + + test('changeset does not overrides spec additions or deletions', () => { + // GIVEN + const currentTemplate = { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'MagicBucket' }, + }, + }, + }; + const newTemplate = { + Resources: { + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { QueueName: 'MagicQueue' }, + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + ResourceChange: { + Action: 'Remove', + LogicalResourceId: 'Bucket', + ResourceType: 'AWS::S3::Bucket', + Details: [], + }, + }, + { + ResourceChange: { + Action: 'Add', + LogicalResourceId: 'Queue', + ResourceType: 'AWS::SQS::Queue', + Details: [], + }, + }, + ], + }); + + // A realistic changeset will include Additions and Removals, but this shows that we don't use the changeset to determine additions or removals + const emptyChangeSetDifferences = fullDiff(currentTemplate, newTemplate, { + Changes: [], + }); + + // THEN + expect(differences.differenceCount).toBe(2); + expect(emptyChangeSetDifferences.differenceCount).toBe(2); + }); + + test('changeset replacements are respected', () => { + // GIVEN + const currentTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'Name1' }, // Immutable prop + }, + }, + }; + const newTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'BucketName' } }, // 'Name1' -> 'Name2' + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Parameters: [ + { + ParameterKey: 'BucketName', + ParameterValue: 'Name2', + }, + ], + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Bucket', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'True', + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'BucketName', + RequiresRecreation: 'Always', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], + }); + + // THEN + expect(differences.differenceCount).toBe(1); + }); + + // This is directly in-line with changeset behavior, + // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html + test('dynamic changeset replacements are considered conditional replacements', () => { + // GIVEN + const currentTemplate = { + Resources: { + Instance: { + Type: 'AWS::EC2::Instance', + Properties: { + ImageId: 'ami-79fd7eee', + KeyName: 'rsa-is-fun', + }, + }, + }, + }; + const newTemplate = { + Resources: { + Instance: { + Type: 'AWS::EC2::Instance', + Properties: { + ImageId: 'ami-79fd7eee', + KeyName: 'but-sha-is-cool', + }, + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Instance', + ResourceType: 'AWS::EC2::Instance', + Replacement: 'Conditional', + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'KeyName', + RequiresRecreation: 'Always', + }, + Evaluation: 'Dynamic', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], + }); + + // THEN + expect(differences.differenceCount).toBe(1); + expect(differences.resources.changes.Instance.changeImpact).toEqual(ResourceImpact.MAY_REPLACE); + expect(differences.resources.changes.Instance.propertyUpdates).toEqual({ + KeyName: { + changeImpact: ResourceImpact.MAY_REPLACE, + isDifferent: true, + oldValue: 'rsa-is-fun', + newValue: 'but-sha-is-cool', + }, + }); + }); + + test('changeset resource replacement is not tracked through references', () => { + // GIVEN + const currentTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'Name1' }, // Immutable prop + }, + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { QueueName: { Ref: 'Bucket' } }, // Immutable prop + }, + Topic: { + Type: 'AWS::SNS::Topic', + Properties: { TopicName: { Ref: 'Queue' } }, // Immutable prop + }, + }, + }; + + // WHEN + const newTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'BucketName' } }, + }, + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { QueueName: { Ref: 'Bucket' } }, + }, + Topic: { + Type: 'AWS::SNS::Topic', + Properties: { TopicName: { Ref: 'Queue' } }, + }, + }, + }; + const differences = fullDiff(currentTemplate, newTemplate, { + Parameters: [ + { + ParameterKey: 'BucketName', + ParameterValue: 'Name1', + }, + ], + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Bucket', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'False', + Details: [], + }, + }, + ], + }); + + // THEN + expect(differences.resources.differenceCount).toBe(0); + }); + + test('Fn::GetAtt short form and long form are equivalent', () => { + // GIVEN + const currentTemplate = { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'BucketName' }, + }, + }, + Outputs: { + BucketArnOneWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, + BucketArnAnotherWay: { 'Fn::GetAtt': 'BucketName.Arn' }, + }, + }; + const newTemplate = { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'BucketName' }, + }, + }, + Outputs: { + BucketArnOneWay: { 'Fn::GetAtt': 'BucketName.Arn' }, + BucketArnAnotherWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate); + + // THEN + expect(differences.differenceCount).toBe(0); + }); + + test('metadata changes are obscured from the diff', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: 'magic-bucket', + Metadata: { + 'aws:cdk:path': '/foo/BucketResource', + }, + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: 'magic-bucket', + Metadata: { + 'aws:cdk:path': '/bar/BucketResource', + }, + }, + }, + }; + + // THEN + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.differenceCount).toBe(0); + }); + + test('single element arrays are equivalent to the single element in DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['SomeResource'], + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: 'SomeResource', + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + + differences = fullDiff(newTemplate, currentTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + }); + + test('array equivalence is independent of element order in DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['SomeResource', 'AnotherResource'], + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['AnotherResource', 'SomeResource'], + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + + differences = fullDiff(newTemplate, currentTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + }); + + test('arrays of different length are considered unequal in DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['SomeResource', 'AnotherResource', 'LastResource'], + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['AnotherResource', 'SomeResource'], + }, + }, + }; + + // dependsOn changes do not appear in the changeset + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.resources.differenceCount).toBe(1); + + differences = fullDiff(newTemplate, currentTemplate, {}); + expect(differences.resources.differenceCount).toBe(1); + }); + + test('arrays that differ only in element order are considered unequal outside of DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: { 'Fn::Select': [0, ['name1', 'name2']] }, + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: { 'Fn::Select': [0, ['name2', 'name1']] }, + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'BucketResource', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'True', + Details: [{ + Evaluation: 'Static', + Target: { + Attribute: 'Properties', + Name: 'BucketName', + RequiresRecreation: 'Always', + }, + }], + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + }); + + test('SAM Resources are rendered with changeset diffs', () => { + // GIVEN + const currentTemplate = { + Resources: { + ServerlessFunction: { + Type: 'AWS::Serverless::Function', + Properties: { + CodeUri: 's3://bermuda-triangle-1337-bucket/old-handler.zip', + }, + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + ServerlessFunction: { + Type: 'AWS::Serverless::Function', + Properties: { + CodeUri: 's3://bermuda-triangle-1337-bucket/new-handler.zip', + }, + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ServerlessFunction', + ResourceType: 'AWS::Lambda::Function', // The SAM transform is applied before the changeset is created, so the changeset has a Lambda resource here! + Replacement: 'False', + Details: [{ + Evaluation: 'Static', + Target: { + Attribute: 'Properties', + Name: 'Code', + RequiresRecreation: 'Never', + }, + }], + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + }); + + test('imports are respected for new stacks', async () => { + // GIVEN + const currentTemplate = {}; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'BucketResource', + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); + }); + + test('imports are respected for existing stacks', async () => { + // GIVEN + const currentTemplate = { + Resources: { + OldResource: { + Type: 'AWS::Something::Resource', + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + OldResource: { + Type: 'AWS::Something::Resource', + }, + BucketResource: { + Type: 'AWS::S3::Bucket', + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'BucketResource', + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); + }); + test('properties that only show up in changeset diff are included in fullDiff', () => { + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + mySsmParameter: ssmParam, + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, + { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'mySsmParameter', + PhysicalResourceId: 'mySsmParameterFromStack', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Value', + RequiresRecreation: 'Never', + Path: '/Properties/Value', + BeforeValue: 'changedddd', + AfterValue: 'sdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedVal', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(0); + expect(diffWithoutChangeSet.resources.changes).toEqual({}); + + expect(diffWithChangeSet.differenceCount).toBe(1); + expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( + { + oldValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'changedddd', + }, + }, + newValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'sdflkja', + }, + }, + resourceTypes: { + oldType: 'AWS::SSM::Parameter', + newType: 'AWS::SSM::Parameter', + }, + propertyDiffs: { + Value: { + oldValue: 'changedddd', + newValue: 'sdflkja', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SSM::Parameter', + newValue: 'AWS::SSM::Parameter', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + ); + + expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); + }); + + test('resources that only show up in changeset diff are included in fullDiff', () => { + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + }, + }, + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, + { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'QueueName', + RequiresRecreation: 'Always', + Path: '/Properties/QueueName', + BeforeValue: 'newValuechangedddd', + AfterValue: 'newValuesdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedVal', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(0); + expect(diffWithoutChangeSet.resources.changes).toEqual({}); + + expect(diffWithChangeSet.differenceCount).toBe(1); + expect(diffWithChangeSet.resources.changes.Queue).toEqual( + { + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuechangedddd', + }, + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuesdflkja', + }, + }, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: 'newValuechangedddd', + newValue: 'newValuesdflkja', + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + ); + + expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); + }); + + test('a resource in the diff that is missing a property has the missing property added to the diff', () => { + // The idea is, we detect 1 change in the template diff -- and we detect another change in the changeset diff. + + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: sqsQueueWithAargs({ waitTime: 10 }), + }, + }; + + const newTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: sqsQueueWithAargs({ waitTime: 20 }), + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, + { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValueNEEEWWWEEERRRRR', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [{ + Target: { Attribute: 'Properties', Name: 'QueueName', RequiresRecreation: 'Always' }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + { + Target: { Attribute: 'Properties', Name: 'ReceiveMessageWaitTimeSeconds', RequiresRecreation: 'Never' }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }], + }, + }, + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedddd', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(1); + expect(diffWithoutChangeSet.resources.changes).toEqual( + { + Queue: { + oldValue: sqsQueueWithAargs({ waitTime: 10 }), + newValue: sqsQueueWithAargs({ waitTime: 20 }), + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + newValue: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: 10, + newValue: 20, + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + }, + ); + + expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed + expect(diffWithChangeSet.resources.changes).toEqual( + { + Queue: { + oldValue: sqsQueueWithAargs({ waitTime: 10 }), + newValue: sqsQueueWithAargs({ waitTime: 20 }), + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { + }, + newValue: { + }, + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: 10, + newValue: 20, + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + }, + ); + }); +}); From f8593d4f5bc043915cfeeba738d4f90256bc3ba7 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sat, 18 May 2024 21:51:52 -0400 Subject: [PATCH 10/36] integ test added and updated --- .../@aws-cdk-testing/cli-integ/lib/aws.ts | 2 + .../cli-integ/resources/cdk-apps/app/app.js | 18 +++++++ .../tests/cli-integ-tests/cli.integtest.ts | 51 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/packages/@aws-cdk-testing/cli-integ/lib/aws.ts b/packages/@aws-cdk-testing/cli-integ/lib/aws.ts index bcf19f4a47e3f..2754fe69e3228 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/aws.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/aws.ts @@ -20,6 +20,7 @@ export class AwsClients { public readonly ecr: AwsCaller; public readonly ecs: AwsCaller; public readonly sso: AwsCaller; + public readonly ssm: AwsCaller; public readonly sns: AwsCaller; public readonly iam: AwsCaller; public readonly lambda: AwsCaller; @@ -38,6 +39,7 @@ export class AwsClients { this.ecr = makeAwsCaller(AWS.ECR, this.config); this.ecs = makeAwsCaller(AWS.ECS, this.config); this.sso = makeAwsCaller(AWS.SSO, this.config); + this.ssm = makeAwsCaller(AWS.SSM, this.config); this.sns = makeAwsCaller(AWS.SNS, this.config); this.iam = makeAwsCaller(AWS.IAM, this.config); this.lambda = makeAwsCaller(AWS.Lambda, this.config); diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index d094055795e27..f68e0002b28a6 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -531,6 +531,22 @@ class DockerStackWithCustomFile extends cdk.Stack { } } +class DiffFromChangeSetStack extends Stack { + constructor(scope, id) { + super(scope, id); + + const queueNameFromParameter = ssm.StringParameter.valueForStringParameter(this, 'for-queue-name-defined-by-ssm-param'); + new sqs.Queue(this, "DiffFromChangeSetQueue", { + queueName: queueNameFromParameter, + }) + + new ssm.StringParameter(this, 'DiffFromChangeSetSSMParam', { + parameterName: 'DiffFromChangeSetSSMParamName', + stringValue: queueNameFromParameter, + }); + } +} + /** * A stack that will never succeed deploying (done in a way that CDK cannot detect but CFN will complain about) */ @@ -685,6 +701,8 @@ switch (stackSet) { const failed = new FailedStack(app, `${stackPrefix}-failed`) + new DiffFromChangeSetStack(app, `${stackPrefix}-queue-name-defined-by-ssm-param`) + // A stack that depends on the failed stack -- used to test that '-e' does not deploy the failing stack const dependsOnFailed = new OutputsStack(app, `${stackPrefix}-depends-on-failed`); dependsOnFailed.addDependency(failed); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index f323110eecfa4..eeaac1c479898 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -944,6 +944,57 @@ integTest('cdk diff --quiet does not print \'There were no differences\' message expect(diff).not.toContain('There were no differences'); })); +integTest('cdk diff picks up changes that are only present in changeset', withDefaultFixture(async (fixture) => { + // GIVEN + const originalQueueName = randomString(); + await fixture.aws.ssm('putParameter', { + Name: 'for-queue-name-defined-by-ssm-param', + Value: originalQueueName, + Type: 'String', + Overwrite: true, + }); + + try { + await fixture.cdkDeploy('queue-name-defined-by-ssm-param'); + + // WHEN + // We want to change the ssm value. Then the CFN changeset will detect that the queue will be changed upon deploy. + const newQueueName = randomString(); + await fixture.aws.ssm('putParameter', { + Name: 'for-queue-name-defined-by-ssm-param', + Value: newQueueName, + Type: 'String', + Overwrite: true, + }); + + const diff = await fixture.cdk(['diff', fixture.fullStackName('queue-name-defined-by-ssm-param')]); + + // THEN + const normalizedPlainTextOutput = diff.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') // remove all color and formatting (bolding, italic, etc) + .replace(/ /g, '') // remove all spaces + .replace(/\n/g, '') // remove all new lines + .replace(/\d+/g, ''); // remove all digits + + const normalizedExpectedOutput = ` + Resources + [~] AWS::SQS::Queue DiffFromChangeSetQueue DiffFromChangeSetQueue06622C07 replace + └─ [~] QueueName (requires replacement) + ├─ [-] ${originalQueueName} + └─ [+] ${newQueueName} + [~] AWS::SSM::Parameter DiffFromChangeSetSSMParam DiffFromChangeSetSSMParam92A9A723 + └─ [~] Value + ├─ [-] ${originalQueueName} + └─ [+] ${newQueueName}` + .replace(/ /g, '') + .replace(/\n/g, '') + .replace(/\d+/g, ''); + + expect(normalizedPlainTextOutput).toContain(normalizedExpectedOutput); + } finally { + await fixture.cdkDestroy('queue-name-defined-by-ssm-param'); + } +})); + integTest('deploy stack with docker asset', withDefaultFixture(async (fixture) => { await fixture.cdkDeploy('docker'); })); From 05956d6a0cbfb43daf2e3b95ccc69bdf8679401a Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 14:55:30 -0400 Subject: [PATCH 11/36] more tests --- .../cloudformation-diff/lib/diff-template.ts | 9 +- .../template-and-changeset-diff-merger.ts | 131 ++++--- .../cloudformation-diff/lib/diff/types.ts | 8 +- ...template-and-changeset-diff-merger.test.ts | 299 ++++++++++++++- .../@aws-cdk/cloudformation-diff/test/util.ts | 350 +++++++++++++++++- 5 files changed, 726 insertions(+), 71 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index c50dd057d446e..483e62d81ff0a 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -1,6 +1,6 @@ // 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 type { DescribeChangeSetOutput } 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'; @@ -8,8 +8,6 @@ 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 }; @@ -56,10 +54,7 @@ export function fullDiff( normalize(newTemplate); let theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { - const changeSetDiff = new TemplateAndChangeSetDiffMerger({ - changeSet: changeSet, - currentTemplateResources: currentTemplate.Resources, - }); + const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); theDiff = new types.TemplateDiff(theDiff); // do this to propagate security changes. diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 0efc2a8c2b738..db3bbba6006b2 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -1,35 +1,74 @@ // 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 } from '@aws-sdk/client-cloudformation'; +import type { DescribeChangeSetOutput, ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import { diffResource } from '.'; import * as types from '../diff/types'; -export type DescribeChangeSetOutput = DescribeChangeSet; - /** * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. */ export class TemplateAndChangeSetDiffMerger { // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. - private UNKNOWN_RESOURCE_TYPE = 'UNKNOWN'; + static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN'; - changeSet: DescribeChangeSetOutput; - currentTemplateResources: {[logicalId: string]: any}; - changeSetResources: types.ChangeSetResources; + /** + * @param parseOldOrNewValues These enum values come from DescribeChangeSetOutput, which indicate if the property resolves to that value after the change or before the change. + */ + static convertResourceFromChangesetToResourceForDiff( + resourceInfoFromChangeset: types.ChangeSetResource, + parseOldOrNewValues: 'BEFORE_VALUES' | 'AFTER_VALUES', + ): types.Resource { + const props: { [logicalId: string]: string | undefined } = {}; + if (parseOldOrNewValues === 'AFTER_VALUES') { + for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { + props[propertyName] = value.afterValue; + } + } else { + for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { + props[propertyName] = value.beforeValue; + } + } + + return { + Type: resourceInfoFromChangeset.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + Properties: props, + }; + } + + static determineChangeSetReplacementMode(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode { + 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.ChangeSetReplacementMode; + } + + changeSet: DescribeChangeSetOutput | undefined; + changeSetResources: types.ChangeSetResources | undefined; constructor( args: { changeSet: DescribeChangeSetOutput; - currentTemplateResources: {[logicalId: string]: any}; }, ) { this.changeSet = args.changeSet; - this.currentTemplateResources = args.currentTemplateResources; this.changeSetResources = this.inspectChangeSet(this.changeSet); } inspectChangeSet(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { - const replacements: types.ChangeSetResources = {}; + const changeSetResources: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { continue; @@ -39,36 +78,33 @@ export class TemplateAndChangeSetDiffMerger { for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { propertiesReplaced[propertyChange.Target.Name] = { - changeSetReplacementMode: this.determineChangeSetReplacementMode(propertyChange), - beforeValue: propertyChange.Target.BeforeValue, - afterValue: propertyChange.Target.AfterValue, + changeSetReplacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), + beforeValue: _maybeJsonParse(propertyChange.Target.BeforeValue), + afterValue: _maybeJsonParse(propertyChange.Target.AfterValue), }; } } - replacements[resourceChange.ResourceChange.LogicalResourceId] = { + changeSetResources[resourceChange.ResourceChange.LogicalResourceId] = { resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True', - resourceType: resourceChange.ResourceChange.ResourceType ?? this.UNKNOWN_RESOURCE_TYPE, // DescribeChanegSet doesn't promise to have the ResourceType... + resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChanegSet doesn't promise to have the ResourceType... properties: propertiesReplaced, }; } - return replacements; - } - - determineChangeSetReplacementMode(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode { - 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 changeSetResources; + + /** + * we will try to parse the afterValue so that downstream processing of the diff can access object properties. + * However, there's not a guarantee that it will work, since clouformation will truncate the afterValue and BeforeValue if they're too long. + */ + function _maybeJsonParse(value: string | undefined): any | undefined { + let result = value; + try { + result = JSON.parse(value ?? ''); + } catch (e) {} + return result; } - - return propertyChange.Target!.RequiresRecreation as types.ChangeSetReplacementMode; } /** @@ -79,18 +115,18 @@ export class TemplateAndChangeSetDiffMerger { * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. */ addChangeSetResourcesToDiff(resourceDiffs: types.DifferenceCollection) { - for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { + for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources ?? {})) { const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { const resourceDiffFromChangeset = diffResource( - this.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'OLD_VALUES'), - this.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'NEW_VALUES'), + TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'BEFORE_VALUES'), + TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'AFTER_VALUES'), ); resourceDiffs.set(logicalId, resourceDiffFromChangeset); } const propertyChangesFromTemplate = resourceDiffs.get(logicalId).propertyUpdates; - for (const propertyName of Object.keys(this.changeSetResources[logicalId]?.properties ?? {})) { + for (const propertyName of Object.keys((this.changeSetResources ?? {})[logicalId]?.properties ?? {})) { if (propertyName in propertyChangesFromTemplate) { // If the property is already marked to be updated, then we don't need to do anything. continue; @@ -106,27 +142,6 @@ export class TemplateAndChangeSetDiffMerger { this.enhanceChangeImpacts(resourceDiffs); } - convertResourceFromChangesetToResourceForDiff( - resourceInfoFromChangeset: types.ChangeSetResource, - parseOldOrNewValues: 'OLD_VALUES' | 'NEW_VALUES', - ): types.Resource { - const props: { [logicalId: string]: string | undefined } = {}; - if (parseOldOrNewValues === 'NEW_VALUES') { - for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { - props[propertyName] = value.afterValue; - } - } else { - for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { - props[propertyName] = value.beforeValue; - } - } - - return { - Type: resourceInfoFromChangeset.resourceType ?? this.UNKNOWN_RESOURCE_TYPE, - Properties: props, - }; - } - enhanceChangeImpacts(resourceDiffs: types.DifferenceCollection) { resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { if ((!change.resourceTypeChanged) && change.resourceType?.includes('AWS::Serverless')) { @@ -135,13 +150,13 @@ export class TemplateAndChangeSetDiffMerger { } change.forEachDifference((type: 'Property' | 'Other', name: string, value: types.Difference | types.PropertyDifference) => { if (type === 'Property') { - if (!this.changeSetResources[logicalId]) { + if (!(this.changeSetResources ?? {})[logicalId]) { (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; (value as types.PropertyDifference).isDifferent = false; return; } - const changeSetReplacementMode = (this.changeSetResources[logicalId]?.properties ?? {})[name]?.changeSetReplacementMode; + const changeSetReplacementMode = ((this.changeSetResources ?? {})[logicalId]?.properties ?? {})[name]?.changeSetReplacementMode; switch (changeSetReplacementMode) { case 'Always': (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; @@ -180,7 +195,7 @@ export class TemplateAndChangeSetDiffMerger { findResourceImports(): string[] { const importedResourceLogicalIds = []; - for (const resourceChange of this.changeSet.Changes ?? []) { + for (const resourceChange of this.changeSet?.Changes ?? []) { if (resourceChange.ResourceChange?.Action === 'Import') { importedResourceLogicalIds.push(resourceChange.ResourceChange.LogicalResourceId!); } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 09a3795fb9b62..7d8017d7c87e1 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -67,9 +67,11 @@ export class TemplateDiff implements ITemplateDiff { this.resources = args.resources || new DifferenceCollection({}); this.unknown = args.unknown || new DifferenceCollection({}); - this.iamChanges = new IamChanges({ - propertyChanges: this.scrutinizablePropertyChanges(IamChanges.IamPropertyScrutinies), - resourceChanges: this.scrutinizableResourceChanges(IamChanges.IamResourceScrutinies), + const x = this.scrutinizablePropertyChanges(IamChanges.IamPropertyScrutinies); // these are missing the old and new values + const y = this.scrutinizableResourceChanges(IamChanges.IamResourceScrutinies); // this has the old and new values but is still being ignored in the iam constructor. + this.iamChanges = new IamChanges({ // changes are not added because oldvalue and newvalue are undefined, which causes early return + propertyChanges: x, + resourceChanges: y, }); this.securityGroupChanges = new SecurityGroupChanges({ diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 8fdeb20b46d6e..577eb0da6e5e5 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,7 +1,10 @@ -import { sqsQueueWithAargs, ssmParam } from './util'; +import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; +import { changeSet, changeSetWithIamChanges, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, sqsQueueWithAargs, ssmParam } from './util'; +import { ChangeSetResource } from '../lib'; +import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; import { fullDiff, ResourceImpact } from '../lib/diff-template'; -describe('changeset', () => { +describe('fullDiff tests', () => { test('changeset overrides spec replacements', () => { // GIVEN const currentTemplate = { @@ -971,4 +974,296 @@ describe('changeset', () => { }, ); }); + + test('IamChanges that are visible only through changeset are added to TemplatedDiff.iamChanges', () => { + // GIVEN + const currentTemplate = {}; + + // WHEN + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, changeSetWithIamChanges); + + // THEN + expect(diffWithChangeSet.iamChanges.statements.additions).toEqual([{ + sid: undefined, + effect: 'Allow', + resources: { + values: [ + 'arn:aws:sqs:us-east-1:012345678901:newAndDifferent', + ], + not: false, + }, + actions: { + values: [ + 'sqs:DeleteMessage', + 'sqs:GetQueueAttributes', + 'sqs:ReceiveMessage', + 'sqs:SendMessage', + ], + not: false, + }, + principals: { + values: [ + 'AWS:{{changeSet:KNOWN_AFTER_APPLY}}', + ], + not: false, + }, + condition: undefined, + serializedIntrinsic: undefined, + }]); + + expect(diffWithChangeSet.iamChanges.statements.removals).toEqual([{ + sid: undefined, + effect: 'Allow', + resources: { + values: [ + 'arn:aws:sqs:us-east-1:012345678901:sdflkja', + ], + not: false, + }, + actions: { + values: [ + 'sqs:DeleteMessage', + 'sqs:GetQueueAttributes', + 'sqs:ReceiveMessage', + 'sqs:SendMessage', + ], + not: false, + }, + principals: { + values: [ + 'AWS:sdflkja', + ], + not: false, + }, + condition: undefined, + serializedIntrinsic: undefined, + }]); + + }); + }); + +describe('method tests', () => { + + test('InspectChangeSet correctly parses changeset', async () => { + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + + // THEN + expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ + resourceWasReplaced: true, + resourceType: 'AWS::SQS::Queue', + properties: { + QueueName: { + changeSetReplacementMode: 'Always', + beforeValue: 'newValuechangedddd', + afterValue: 'newValuesdflkja', + }, + }, + }); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ + resourceWasReplaced: false, + resourceType: 'AWS::SSM::Parameter', + properties: { + Value: { + changeSetReplacementMode: 'Never', + beforeValue: 'changedddd', + afterValue: 'sdflkja', + }, + }, + }); + }); + + test('TemplateAndChangeSetDiffMerger constructor can handle undefined changeset', async () => { + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); + + // THEN + expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual({}); + }); + + test('TemplateAndChangeSetDiffMerger constructor can handle undefined changes in changset.Changes', async () => { + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); + + // THEN + expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithMissingChanges); + }); + + test('TemplateAndChangeSetDiffMerger constructor can handle partially defined changes in changset.Changes', async () => { + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); + + // THEN + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithPartiallyFilledChanges); + expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ + resourceWasReplaced: false, + resourceType: 'AWS::SSM::Parameter', + properties: {}, + }); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ + resourceWasReplaced: true, + resourceType: 'UNKNOWN', + properties: { + QueueName: { + changeSetReplacementMode: 'Always', + beforeValue: undefined, + afterValue: undefined, + }, + }, + }); + }); + + test('TemplateAndChangeSetDiffMerger constructor can handle undefined Details in changset.Changes', async () => { + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); + + // THEN + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithUndefinedDetails); + expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(1); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ + resourceWasReplaced: true, + resourceType: 'UNKNOWN', + properties: {}, + }); + }); + + test('determineChangeSetReplacementMode can evaluate missing Target', async () => { + // GIVEN + const propertyChangeWithMissingTarget = { + Target: undefined, + }; + + // WHEN + const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingTarget); + + // THEN + expect(changeSetReplacementMode).toEqual('Conditionally'); + }); + + test('determineChangeSetReplacementMode can evaluate missing RequiresRecreation', async () => { + // GIVEN + const propertyChangeWithMissingTargetDetail = { + Target: { RequiresRecreation: undefined }, + }; + + // WHEN + const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingTargetDetail); + + // THEN + expect(changeSetReplacementMode).toEqual('Conditionally'); + }); + + test('determineChangeSetReplacementMode can evaluate Always and Static', async () => { + // GIVEN + const propertyChangeWithAlwaysStatic: ResourceChangeDetail = { + Target: { RequiresRecreation: 'Always' }, + Evaluation: 'Static', + }; + + // WHEN + const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithAlwaysStatic); + + // THEN + expect(changeSetReplacementMode).toEqual('Always'); + }); + + test('determineChangeSetReplacementMode can evaluate always dynamic', async () => { + // GIVEN + const propertyChangeWithAlwaysDynamic: ResourceChangeDetail = { + Target: { RequiresRecreation: 'Always' }, + Evaluation: 'Dynamic', + }; + + // WHEN + const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithAlwaysDynamic); + + // THEN + expect(changeSetReplacementMode).toEqual('Conditionally'); + }); + + test('determineChangeSetReplacementMode with missing Evaluation', async () => { + // GIVEN + const propertyChangeWithMissingEvaluation: ResourceChangeDetail = { + Target: { RequiresRecreation: 'Always' }, + Evaluation: undefined, + }; + + // WHEN + const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingEvaluation); + + // THEN + expect(changeSetReplacementMode).toEqual('Always'); + }); + + test('convertResourceFromChangesetToResourceForDiff with missing resourceType and properties', async () => { + // GIVEN + const changeSetResource: ChangeSetResource = { + resourceWasReplaced: false, + resourceType: undefined, + properties: undefined, + }; + + // WHEN + const resourceAfterChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( + changeSetResource, + 'AFTER_VALUES', + ); + const resourceBeforeChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( + changeSetResource, + 'BEFORE_VALUES', + ); + + // THEN + expect(resourceBeforeChange).toEqual({ + Type: 'UNKNOWN', + Properties: {}, + }); + + expect(resourceAfterChange).toEqual({ + Type: 'UNKNOWN', + Properties: {}, + }); + }); + + test('convertResourceFromChangesetToResourceForDiff with fully filled input', async () => { + // GIVEN + const changeSetResource: ChangeSetResource = { + resourceWasReplaced: false, + resourceType: 'CDK::IS::GREAT', + properties: { + C: { + changeSetReplacementMode: 'Always', + beforeValue: 'changedddd', + afterValue: 'sdflkja', + }, + }, + }; + + // WHEN + const resourceAfterChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( + changeSetResource, + 'AFTER_VALUES', + ); + const resourceBeforeChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( + changeSetResource, + 'BEFORE_VALUES', + ); + + // THEN + expect(resourceBeforeChange).toEqual({ + Type: 'CDK::IS::GREAT', + Properties: { C: 'changedddd' }, + }); + + expect(resourceAfterChange).toEqual({ + Type: 'CDK::IS::GREAT', + Properties: { C: 'sdflkja' }, + }); + }); + +}); \ No newline at end of file diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index 2fae953f53a3d..eb2c550197b31 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -1,3 +1,5 @@ +import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; + export function template(resources: {[key: string]: any}) { return { Resources: resources }; } @@ -94,4 +96,350 @@ export function sqsQueueWithAargs(args: { waitTime: number }) { ReceiveMessageWaitTimeSeconds: args.waitTime, }, }; -} \ No newline at end of file +} + +export const changeSet: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'QueueName', + RequiresRecreation: 'Always', + Path: '/Properties/QueueName', + BeforeValue: 'newValuechangedddd', + AfterValue: 'newValuesdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + BeforeContext: '{"Properties":{"QueueName":"newValuechangedddd","ReceiveMessageWaitTimeSeconds":"20"},"Metadata":{"aws:cdk:path":"cdkbugreport/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}', + AfterContext: '{"Properties":{"QueueName":"newValuesdflkja","ReceiveMessageWaitTimeSeconds":"20"},"Metadata":{"aws:cdk:path":"cdkbugreport/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'mySsmParameter', + PhysicalResourceId: 'mySsmParameterFromStack', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Value', + RequiresRecreation: 'Never', + Path: '/Properties/Value', + BeforeValue: 'changedddd', + AfterValue: 'sdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + BeforeContext: '{"Properties":{"Value":"changedddd","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport/mySsmParameter/Resource"}}', + AfterContext: '{"Properties":{"Value":"sdflkja","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport/mySsmParameter/Resource"}}', + }, + }, + ], + ChangeSetName: 'newesteverr2223', + ChangeSetId: 'arn:aws:cloudformation:us-east-1:012345678901:changeSet/newesteverr2223/3cb73e2d-d1c4-4331-9255-c978e496b6d1', + StackId: 'arn:aws:cloudformation:us-east-1:012345678901:stack/cdkbugreport/af695110-1570-11ef-a065-0eb1173d997f', + StackName: 'cdkbugreport', + Parameters: [ + { + ParameterKey: 'BootstrapVersion', + ParameterValue: '/cdk-bootstrap/000000000/version', + ResolvedValue: '20', + }, + { + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'testbugreport', + ResolvedValue: 'sdflkja', + }, + ], + ExecutionStatus: 'AVAILABLE', + Status: 'CREATE_COMPLETE', +}; + +export const changeSetWithMissingChanges = { + Changes: [ + { + Type: undefined, + ResourceChange: undefined, + }, + ], +}; + +export const changeSetWithPartiallyFilledChanges: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', + ResourceType: undefined, + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'QueueName', + RequiresRecreation: 'Always', + Path: '/Properties/QueueName', + AttributeChangeType: 'Modify', + }, + Evaluation: undefined, + ChangeSource: 'DirectModification', + }, + ], + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'mySsmParameter', + PhysicalResourceId: 'mySsmParameterFromStack', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: undefined, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], +}; + +export const changeSetWithUndefinedDetails: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', + ResourceType: undefined, + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: undefined, + }, + }, + ], +}; + +// this is the output of describechangeset with --include-property-values +export const changeSetWithIamChanges: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'MyRoleDefaultPolicy', + PhysicalResourceId: 'cdkbu-MyRol-6q4vdfo8rIJG', + ResourceType: 'AWS::IAM::Policy', + Replacement: 'False', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'PolicyDocument', + RequiresRecreation: 'Never', + Path: '/Properties/PolicyDocument', + BeforeValue: '{"Version":"2012-10-17","Statement":[{"Action":["sqs:DeleteMessage","sqs:GetQueueAttributes","sqs:ReceiveMessage","sqs:SendMessage"],"Resource":"arn:aws:sqs:us-east-1:012345678901:sdflkja","Effect":"Allow"}]}', + AfterValue: '{"Version":"2012-10-17","Statement":[{"Action":["sqs:DeleteMessage","sqs:GetQueueAttributes","sqs:ReceiveMessage","sqs:SendMessage"],"Resource":"arn:aws:sqs:us-east-1:012345678901:newAndDifferent","Effect":"Allow"}]}', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + { + Target: { + Attribute: 'Properties', + Name: 'Roles', + RequiresRecreation: 'Never', + Path: '/Properties/Roles/0', + BeforeValue: 'sdflkja', + AfterValue: '{{changeSet:KNOWN_AFTER_APPLY}}', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Dynamic', + ChangeSource: 'DirectModification', + }, + { + Target: { + Attribute: 'Properties', + Name: 'Roles', + RequiresRecreation: 'Never', + Path: '/Properties/Roles/0', + BeforeValue: 'sdflkja', + AfterValue: '{{changeSet:KNOWN_AFTER_APPLY}}', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'ResourceReference', + CausingEntity: 'MyRole', + }, + ], + BeforeContext: '{"Properties":{"PolicyDocument":{"Version":"2012-10-17","Statement":[{"Action":["sqs:DeleteMessage","sqs:GetQueueAttributes","sqs:ReceiveMessage","sqs:SendMessage"],"Resource":"arn:aws:sqs:us-east-1:012345678901:sdflkja","Effect":"Allow"}]},"Roles":["sdflkja"],"PolicyName":"MyRoleDefaultPolicy"},"Metadata":{"aws:cdk:path":"cdkbugreport2/MyRole/DefaultPolicy/Resource"}}', + AfterContext: '{"Properties":{"PolicyDocument":{"Version":"2012-10-17","Statement":[{"Action":["sqs:DeleteMessage","sqs:GetQueueAttributes","sqs:ReceiveMessage","sqs:SendMessage"],"Resource":"arn:aws:sqs:us-east-1:012345678901:newAndDifferent","Effect":"Allow"}]},"Roles":["{{changeSet:KNOWN_AFTER_APPLY}}"],"PolicyName":"MyRoleDefaultPolicy"},"Metadata":{"aws:cdk:path":"cdkbugreport2/MyRole/DefaultPolicy/Resource"}}', + }, + }, + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'MyRole', + PhysicalResourceId: 'sdflkja', + ResourceType: 'AWS::IAM::Role', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'RoleName', + RequiresRecreation: 'Always', + Path: '/Properties/RoleName', + BeforeValue: 'sdflkja', + AfterValue: 'newAndDifferent', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + BeforeContext: '{"Properties":{"RoleName":"sdflkja","Description":"This is a custom role for my Lambda function","AssumeRolePolicyDocument":{"Version":"2012-10-17","Statement":[{"Action":"sts:AssumeRole","Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"}}]}},"Metadata":{"aws:cdk:path":"cdkbugreport2/MyRole/Resource"}}', + AfterContext: '{"Properties":{"RoleName":"newAndDifferent","Description":"This is a custom role for my Lambda function","AssumeRolePolicyDocument":{"Version":"2012-10-17","Statement":[{"Action":"sts:AssumeRole","Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"}}]}},"Metadata":{"aws:cdk:path":"cdkbugreport2/MyRole/Resource"}}', + }, + }, + { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuesdflkja', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'QueueName', + RequiresRecreation: 'Always', + Path: '/Properties/QueueName', + BeforeValue: 'newValuesdflkja', + AfterValue: 'newValuenewAndDifferent', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + BeforeContext: '{"Properties":{"QueueName":"newValuesdflkja","ReceiveMessageWaitTimeSeconds":"20"},"Metadata":{"aws:cdk:path":"cdkbugreport2/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}', + AfterContext: '{"Properties":{"QueueName":"newValuenewAndDifferent","ReceiveMessageWaitTimeSeconds":"20"},"Metadata":{"aws:cdk:path":"cdkbugreport2/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'mySsmParameter', + PhysicalResourceId: 'mySsmParameterFromStack', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Value', + RequiresRecreation: 'Never', + Path: '/Properties/Value', + BeforeValue: 'sdflkja', + AfterValue: 'newAndDifferent', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + BeforeContext: '{"Properties":{"Value":"sdflkja","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport2/mySsmParameter/Resource"}}', + AfterContext: '{"Properties":{"Value":"newAndDifferent","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport2/mySsmParameter/Resource"}}', + }, + }, + ], + ChangeSetName: 'newIamStuff', + ChangeSetId: 'arn:aws:cloudformation:us-east-1:012345678901:changeSet/newIamStuff/b19829fe-20d6-43ba-83b2-d22c42c00d08', + StackId: 'arn:aws:cloudformation:us-east-1:012345678901:stack/cdkbugreport2/c4cd77c0-15f7-11ef-a7a6-0affeddeb3e1', + StackName: 'cdkbugreport2', + Parameters: [ + { + ParameterKey: 'BootstrapVersion', + ParameterValue: '/cdk-bootstrap/hnb659fds/version', + ResolvedValue: '20', + }, + { + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'testbugreport', + ResolvedValue: 'newAndDifferent', + }, + ], + ExecutionStatus: 'AVAILABLE', + Status: 'CREATE_COMPLETE', + NotificationARNs: [], + RollbackConfiguration: {}, + Capabilities: [ + 'CAPABILITY_NAMED_IAM', + ], + IncludeNestedStacks: false, +} +; + From 52129b0de28f93e6ced4ef7d7a7b25b2627c3378 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 14:57:27 -0400 Subject: [PATCH 12/36] clean --- packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 7d8017d7c87e1..09a3795fb9b62 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -67,11 +67,9 @@ export class TemplateDiff implements ITemplateDiff { this.resources = args.resources || new DifferenceCollection({}); this.unknown = args.unknown || new DifferenceCollection({}); - const x = this.scrutinizablePropertyChanges(IamChanges.IamPropertyScrutinies); // these are missing the old and new values - const y = this.scrutinizableResourceChanges(IamChanges.IamResourceScrutinies); // this has the old and new values but is still being ignored in the iam constructor. - this.iamChanges = new IamChanges({ // changes are not added because oldvalue and newvalue are undefined, which causes early return - propertyChanges: x, - resourceChanges: y, + this.iamChanges = new IamChanges({ + propertyChanges: this.scrutinizablePropertyChanges(IamChanges.IamPropertyScrutinies), + resourceChanges: this.scrutinizableResourceChanges(IamChanges.IamResourceScrutinies), }); this.securityGroupChanges = new SecurityGroupChanges({ From 2683ed741387ac60654287d1e9cde75b860f5502 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 16:59:50 -0400 Subject: [PATCH 13/36] tests are passing --- .../cloudformation-diff/lib/diff-template.ts | 1 + .../template-and-changeset-diff-merger.ts | 21 +-- ...template-and-changeset-diff-merger.test.ts | 156 +++++++++++++++++- 3 files changed, 166 insertions(+), 12 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 483e62d81ff0a..c9bccdd94f465 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -56,6 +56,7 @@ export function fullDiff( if (changeSet) { const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); + changeSetDiff.hydrateChangeImpacts(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); theDiff = new types.TemplateDiff(theDiff); // do this to propagate security changes. } else if (isImport) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index db3bbba6006b2..f21c6d57190ab 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -56,7 +56,7 @@ export class TemplateAndChangeSetDiffMerger { } changeSet: DescribeChangeSetOutput | undefined; - changeSetResources: types.ChangeSetResources | undefined; + changeSetResources: types.ChangeSetResources; constructor( args: { @@ -64,10 +64,10 @@ export class TemplateAndChangeSetDiffMerger { }, ) { this.changeSet = args.changeSet; - this.changeSetResources = this.inspectChangeSet(this.changeSet); + this.changeSetResources = this.createChangeSetResources(this.changeSet); } - inspectChangeSet(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { + createChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { const changeSetResources: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { @@ -115,7 +115,7 @@ export class TemplateAndChangeSetDiffMerger { * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. */ addChangeSetResourcesToDiff(resourceDiffs: types.DifferenceCollection) { - for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources ?? {})) { + for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { const resourceDiffFromChangeset = diffResource( @@ -126,7 +126,7 @@ export class TemplateAndChangeSetDiffMerger { } const propertyChangesFromTemplate = resourceDiffs.get(logicalId).propertyUpdates; - for (const propertyName of Object.keys((this.changeSetResources ?? {})[logicalId]?.properties ?? {})) { + for (const propertyName of Object.keys(this.changeSetResources[logicalId].properties ?? {})) { if (propertyName in propertyChangesFromTemplate) { // If the property is already marked to be updated, then we don't need to do anything. continue; @@ -138,11 +138,12 @@ export class TemplateAndChangeSetDiffMerger { resourceDiffs.get(logicalId).setPropertyChange(propertyName, emptyPropertyDiff); } } - - this.enhanceChangeImpacts(resourceDiffs); } - enhanceChangeImpacts(resourceDiffs: types.DifferenceCollection) { + /** + * should be invoked after addChangeSetResourcesToDiff so that the change impacts are included. + */ + hydrateChangeImpacts(resourceDiffs: types.DifferenceCollection) { resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { if ((!change.resourceTypeChanged) && change.resourceType?.includes('AWS::Serverless')) { // CFN applies the SAM transform before creating the changeset, so the changeset contains no information about SAM resources @@ -150,13 +151,13 @@ export class TemplateAndChangeSetDiffMerger { } change.forEachDifference((type: 'Property' | 'Other', name: string, value: types.Difference | types.PropertyDifference) => { if (type === 'Property') { - if (!(this.changeSetResources ?? {})[logicalId]) { + if (!this.changeSetResources[logicalId]) { (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; (value as types.PropertyDifference).isDifferent = false; return; } - const changeSetReplacementMode = ((this.changeSetResources ?? {})[logicalId]?.properties ?? {})[name]?.changeSetReplacementMode; + const changeSetReplacementMode = (this.changeSetResources[logicalId].properties ?? {})[name]?.changeSetReplacementMode; switch (changeSetReplacementMode) { case 'Always': (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 577eb0da6e5e5..2a56de7940375 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,6 +1,6 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; -import { changeSet, changeSetWithIamChanges, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, sqsQueueWithAargs, ssmParam } from './util'; -import { ChangeSetResource } from '../lib'; +import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges } from './util'; +import { ChangeSetResource, DifferenceCollection, Resource, ResourceDifference } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; import { fullDiff, ResourceImpact } from '../lib/diff-template'; @@ -1266,4 +1266,156 @@ describe('method tests', () => { }); }); + test('addChangeSetResourcesToDiff can add resources from changeset', async () => { + // GIVEN + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + + //WHEN + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + + // THEN + expect(resources.differenceCount).toBe(2); + expect(resources.changes.mySsmParameter.isUpdate).toBe(true); + expect(resources.changes.mySsmParameter).toEqual({ + oldValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'changedddd', + }, + }, + newValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'sdflkja', + }, + }, + resourceTypes: { + oldType: 'AWS::SSM::Parameter', + newType: 'AWS::SSM::Parameter', + }, + propertyDiffs: { + Value: { + oldValue: 'changedddd', + newValue: 'sdflkja', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SSM::Parameter', + newValue: 'AWS::SSM::Parameter', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); + + expect(resources.changes.Queue.isUpdate).toBe(true); + expect(resources.changes.Queue).toEqual({ + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuechangedddd', + }, + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuesdflkja', + }, + }, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: 'newValuechangedddd', + newValue: 'newValuesdflkja', + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); + }); + + test('addChangeSetResourcesToDiff can add resources from empty changeset', async () => { + // GIVEN + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); + + //WHEN + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + + // THEN + expect(resources.differenceCount).toBe(0); + expect(resources.changes).toEqual({}); + + }); + + test('addChangeSetResourcesToDiff can add resources from changeset that have undefined resourceType and Details', async () => { + // GIVEN + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); + + //WHEN + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + + // THEN + expect(resources.differenceCount).toBe(0); + expect(resources.changes).toEqual({}); + + }); + + test('addChangeSetResourcesToDiff can add resources from changeset that have undefined properties', async () => { + // GIVEN + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); + + //WHEN + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + + // THEN + expect(resources.differenceCount).toBe(1); + expect(resources.changes.Queue.isUpdate).toBe(true); + expect(resources.changes.Queue.oldValue).toEqual({ + Type: 'UNKNOWN', + Properties: { QueueName: undefined }, + }); + expect(resources.changes.Queue.oldValue).toEqual(resources.changes.Queue.newValue); + expect(resources.changes.Queue.propertyUpdates.QueueName).toEqual({ + oldValue: {}, + newValue: {}, + isDifferent: true, + changeImpact: undefined, // will be filled in by enhanceChangeImpact + }); + }); + + // test('hydrateChangeImpactFromChangeset can handle blank change', async () => { + // // GIVEN + // const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); + // const queue = new ResourceDifference(undefined, undefined, { resourceType: {}, propertyDiffs: {}, otherDiffs: {} }); + // const logicalId = 'Queue'; + + // //WHEN + // templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeset(logicalId, queue); + + // // THEN + // expect(queue.isDifferent).toBe(true); + // }); + }); \ No newline at end of file From 56e20d8aaf581283ccc59c0880499ff500d1c347 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 18:20:51 -0400 Subject: [PATCH 14/36] fully tested --- .../cloudformation-diff/lib/diff-template.ts | 5 +- .../template-and-changeset-diff-merger.ts | 77 ++--- ...template-and-changeset-diff-merger.test.ts | 279 +++++++++++++++++- .../@aws-cdk/cloudformation-diff/test/util.ts | 4 +- 4 files changed, 311 insertions(+), 54 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index c9bccdd94f465..32944701b81f9 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -56,7 +56,10 @@ export function fullDiff( if (changeSet) { const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); - changeSetDiff.hydrateChangeImpacts(theDiff.resources); + theDiff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => + changeSetDiff.hydrateChangeImpactFromChangeSet(logicalId, change), + ); + // changeSetDiff.hydrateChangeImpacts(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); theDiff = new types.TemplateDiff(theDiff); // do this to propagate security changes. } else if (isImport) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index f21c6d57190ab..641b170675299 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -61,12 +61,16 @@ export class TemplateAndChangeSetDiffMerger { constructor( args: { changeSet: DescribeChangeSetOutput; + changeSetResources?: types.ChangeSetResources; // used for testing -- otherwise, don't populate }, ) { this.changeSet = args.changeSet; - this.changeSetResources = this.createChangeSetResources(this.changeSet); + this.changeSetResources = args.changeSetResources ?? this.createChangeSetResources(this.changeSet); } + /** + * use information from the changeset to populate details that are missing in the templateDiff + */ createChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { const changeSetResources: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { @@ -101,7 +105,7 @@ export class TemplateAndChangeSetDiffMerger { function _maybeJsonParse(value: string | undefined): any | undefined { let result = value; try { - result = JSON.parse(value ?? ''); + result = JSON.parse(value ?? ''); // TODO -- have a test that fails here !!! partial json!! {"blah": "hii } catch (e) {} return result; } @@ -143,45 +147,44 @@ export class TemplateAndChangeSetDiffMerger { /** * should be invoked after addChangeSetResourcesToDiff so that the change impacts are included. */ - hydrateChangeImpacts(resourceDiffs: types.DifferenceCollection) { - resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { - if ((!change.resourceTypeChanged) && 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 | types.PropertyDifference) => { - if (type === 'Property') { - if (!this.changeSetResources[logicalId]) { + hydrateChangeImpactFromChangeSet(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 | types.PropertyDifference) => { + if (type === 'Property') { + if (!this.changeSetResources[logicalId]) { + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; + (value as types.PropertyDifference).isDifferent = false; + return; + } + + const changeSetReplacementMode = (this.changeSetResources[logicalId].properties ?? {})[name]?.changeSetReplacementMode; + switch (changeSetReplacementMode) { + case 'Always': + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; + break; + case 'Never': + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_UPDATE; + break; + case 'Conditionally': + (value as types.PropertyDifference).changeImpact = types.ResourceImpact.MAY_REPLACE; + break; + case undefined: (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; (value as types.PropertyDifference).isDifferent = false; - return; - } - - const changeSetReplacementMode = (this.changeSetResources[logicalId].properties ?? {})[name]?.changeSetReplacementMode; - switch (changeSetReplacementMode) { - case 'Always': - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; - break; - case 'Never': - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_UPDATE; - break; - case 'Conditionally': - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.MAY_REPLACE; - break; - case undefined: - (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; - (value as types.PropertyDifference).isDifferent = false; - break; + break; // otherwise, defer to the changeImpact from `diffTemplate` - } - } else if (type === 'Other') { - switch (name) { - case 'Metadata': - change.setOtherChange('Metadata', new types.Difference(value.newValue, value.newValue)); - break; - } } - }); + } else if (type === 'Other') { + switch (name) { + case 'Metadata': + change.setOtherChange('Metadata', new types.Difference(value.newValue, value.newValue)); + break; + } + } }); } diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 2a56de7940375..0e3224a9d020b 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,8 +1,7 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges } from './util'; -import { ChangeSetResource, DifferenceCollection, Resource, ResourceDifference } from '../lib'; +import { fullDiff, Difference, PropertyDifference, ResourceDifference, ResourceImpact, ChangeSetResource, DifferenceCollection, Resource } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; -import { fullDiff, ResourceImpact } from '../lib/diff-template'; describe('fullDiff tests', () => { test('changeset overrides spec replacements', () => { @@ -1405,17 +1404,271 @@ describe('method tests', () => { }); }); - // test('hydrateChangeImpactFromChangeset can handle blank change', async () => { - // // GIVEN - // const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); - // const queue = new ResourceDifference(undefined, undefined, { resourceType: {}, propertyDiffs: {}, otherDiffs: {} }); - // const logicalId = 'Queue'; + test('hydrateChangeImpactFromChangeset can handle blank change', async () => { + // GIVEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); + const queue = new ResourceDifference(undefined, undefined, { resourceType: {}, propertyDiffs: {}, otherDiffs: {} }); + const logicalId = 'Queue'; + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); + }); + + test('hydrateChangeImpactFromChangeset ignores changes that are not in changeset', async () => { + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: {}, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); + const logicalId = 'Queue'; + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); + }); + + test('hydrateChangeImpactFromChangeset can handle undefined properties', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: {} as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); + }); + + test('hydrateChangeImpactFromChangeset can handle empty properties', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: {}, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); + }); + + test('hydrateChangeImpactFromChangeset can handle property without changeSetReplacementMode', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: { + QueueName: {} as any, + }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); + }); + + test('hydrateChangeImpactFromChangeset handles Never case', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: { + QueueName: { + changeSetReplacementMode: 'Never', + }, + }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.changeImpact).toBe('WILL_UPDATE'); + expect(queue.isDifferent).toBe(true); + }); + + test('hydrateChangeImpactFromChangeset handles Conditionally case', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: { + QueueName: { + changeSetReplacementMode: 'Conditionally', + }, + }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.changeImpact).toBe('MAY_REPLACE'); + expect(queue.isDifferent).toBe(true); + }); + + test('hydrateChangeImpactFromChangeset handles Always case', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: { + QueueName: { + changeSetReplacementMode: 'Always', + }, + }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + + // THEN + expect(queue.changeImpact).toBe('WILL_REPLACE'); + expect(queue.isDifferent).toBe(true); + }); + + test('hydrateChangeImpactFromChangeset returns if AWS::Serverless is resourcetype', async () => { + // GIVEN + const logicalId = 'Queue'; - // //WHEN - // templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeset(logicalId, queue); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: { + QueueName: { + changeSetReplacementMode: 'Always', + }, + }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AAWS::Serverless::IDK', Properties: { QueueName: 'first' } }, + { Type: 'AAWS::Serverless::IDK', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::Serverless::IDK', newType: 'AWS::Serverless::IDK' }, + propertyDiffs: { + QueueName: new PropertyDifference( 'first', 'second', + { changeImpact: ResourceImpact.WILL_ORPHAN }), // choose will_orphan to show that we're ignoring changeset + }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); - // // THEN - // expect(queue.isDifferent).toBe(true); - // }); + // THEN + expect(queue.changeImpact).toBe('WILL_ORPHAN'); + expect(queue.isDifferent).toBe(true); + }); -}); \ No newline at end of file +}); diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index eb2c550197b31..3a47cad1fd6ca 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -440,6 +440,4 @@ export const changeSetWithIamChanges: DescribeChangeSetOutput = { 'CAPABILITY_NAMED_IAM', ], IncludeNestedStacks: false, -} -; - +}; From 54a920eff074d92dc8aea817cde961c85c43134c Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 18:38:38 -0400 Subject: [PATCH 15/36] fully tested --- .../lib/diff/template-and-changeset-diff-merger.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 641b170675299..99310405cce89 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -181,6 +181,7 @@ export class TemplateAndChangeSetDiffMerger { } else if (type === 'Other') { switch (name) { case 'Metadata': + // we want to ignore matadata changes in the diff, so compare newValue against newValue. change.setOtherChange('Metadata', new types.Difference(value.newValue, value.newValue)); break; } From 416f4b6df8fa99ec4557b56d45b8a7cbbc893034 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 18:43:02 -0400 Subject: [PATCH 16/36] make _maybeParse better --- .../lib/diff/template-and-changeset-diff-merger.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 99310405cce89..7ba391435089a 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -103,11 +103,11 @@ export class TemplateAndChangeSetDiffMerger { * However, there's not a guarantee that it will work, since clouformation will truncate the afterValue and BeforeValue if they're too long. */ function _maybeJsonParse(value: string | undefined): any | undefined { - let result = value; try { - result = JSON.parse(value ?? ''); // TODO -- have a test that fails here !!! partial json!! {"blah": "hii - } catch (e) {} - return result; + return JSON.parse(value ?? ''); + } catch (e) { + return value; + } } } From c15f9ef28e8699e13950da25934646d99aefb10b Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Sun, 19 May 2024 18:46:24 -0400 Subject: [PATCH 17/36] make _maybeParse better --- .../test/template-and-changeset-diff-merger.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 0e3224a9d020b..ae4182c83ce73 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,6 +1,6 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges } from './util'; -import { fullDiff, Difference, PropertyDifference, ResourceDifference, ResourceImpact, ChangeSetResource, DifferenceCollection, Resource } from '../lib'; +import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, ChangeSetResource, DifferenceCollection, Resource } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; describe('fullDiff tests', () => { From bd9eccf386bec71490b2a1d2f3399417cda3d5e5 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 00:05:40 -0400 Subject: [PATCH 18/36] rename describe --- .../test/template-and-changeset-diff-merger.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index ae4182c83ce73..88b016bf06efe 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -3,7 +3,7 @@ import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledCha import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, ChangeSetResource, DifferenceCollection, Resource } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; -describe('fullDiff tests', () => { +describe('fullDiff tests that include changeset', () => { test('changeset overrides spec replacements', () => { // GIVEN const currentTemplate = { From 3b986cbb65958f0ceb83e4488f0f5d92acf87e16 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 00:20:20 -0400 Subject: [PATCH 19/36] clean --- .../@aws-cdk/cloudformation-diff/lib/diff-template.ts | 4 ++-- .../lib/diff/template-and-changeset-diff-merger.ts | 11 ++++------- .../test/template-and-changeset-diff-merger.test.ts | 8 ++++---- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 32944701b81f9..77d38ba29bfa7 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -54,12 +54,12 @@ export function fullDiff( normalize(newTemplate); let theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { + // These methods mutate the state of theDiff, using the changeSet. const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); - changeSetDiff.addChangeSetResourcesToDiff(theDiff.resources); + changeSetDiff.addChangeSetResourcesToDiffResources(theDiff.resources); theDiff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => changeSetDiff.hydrateChangeImpactFromChangeSet(logicalId, change), ); - // changeSetDiff.hydrateChangeImpacts(theDiff.resources); changeSetDiff.addImportInformation(theDiff.resources); theDiff = new types.TemplateDiff(theDiff); // do this to propagate security changes. } else if (isImport) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 7ba391435089a..4066999e39f89 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -118,7 +118,7 @@ export class TemplateAndChangeSetDiffMerger { * - One case when this can happen is when a resource is added to the stack through the changeset. * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. */ - addChangeSetResourcesToDiff(resourceDiffs: types.DifferenceCollection) { + addChangeSetResourcesToDiffResources(resourceDiffs: types.DifferenceCollection) { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { @@ -136,7 +136,7 @@ export class TemplateAndChangeSetDiffMerger { continue; } - // This property diff will be hydrated when enhanceChangeImpacts is called. + // This property diff will be hydrated when hydrateChangeImpactFromChangeSet is called. const emptyPropertyDiff = new types.PropertyDifference({}, {}, {}); emptyPropertyDiff.isDifferent = true; resourceDiffs.get(logicalId).setPropertyChange(propertyName, emptyPropertyDiff); @@ -144,9 +144,6 @@ export class TemplateAndChangeSetDiffMerger { } } - /** - * should be invoked after addChangeSetResourcesToDiff so that the change impacts are included. - */ hydrateChangeImpactFromChangeSet(logicalId: string, change: types.ResourceDifference) { // resourceType getter throws an error if resourceTypeChanged if ((change.resourceTypeChanged === true) || change.resourceType?.includes('AWS::Serverless')) { @@ -176,12 +173,12 @@ export class TemplateAndChangeSetDiffMerger { (value as types.PropertyDifference).changeImpact = types.ResourceImpact.NO_CHANGE; (value as types.PropertyDifference).isDifferent = false; break; - // otherwise, defer to the changeImpact from `diffTemplate` + // otherwise, defer to the changeImpact from the template diff } } else if (type === 'Other') { switch (name) { case 'Metadata': - // we want to ignore matadata changes in the diff, so compare newValue against newValue. + // we want to ignore metadata changes in the diff, so compare newValue against newValue. change.setOtherChange('Metadata', new types.Difference(value.newValue, value.newValue)); break; } diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 88b016bf06efe..d4b4a0a45a315 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1271,7 +1271,7 @@ describe('method tests', () => { const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); // THEN expect(resources.differenceCount).toBe(2); @@ -1358,7 +1358,7 @@ describe('method tests', () => { const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); // THEN expect(resources.differenceCount).toBe(0); @@ -1372,7 +1372,7 @@ describe('method tests', () => { const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); // THEN expect(resources.differenceCount).toBe(0); @@ -1386,7 +1386,7 @@ describe('method tests', () => { const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiff(resources); + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); // THEN expect(resources.differenceCount).toBe(1); From fff90ccf2776bf42dfec5295cb5a08728c2e5ee5 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 08:09:22 -0400 Subject: [PATCH 20/36] fix build --- packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts | 4 +++- .../lib/diff/template-and-changeset-diff-merger.ts | 7 +++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 77d38ba29bfa7..e9dd5fd3fc2ca 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -1,6 +1,6 @@ // 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 } from '@aws-sdk/client-cloudformation'; +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'; @@ -8,6 +8,8 @@ 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 }; diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 4066999e39f89..81b65272197ad 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -1,9 +1,12 @@ // 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, ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; +import type { DescribeChangeSetOutput as DescribeChangeSet, ResourceChangeDetail as RCD } from '@aws-sdk/client-cloudformation'; import { diffResource } from '.'; import * as types from '../diff/types'; +export type DescribeChangeSetOutput = DescribeChangeSet; +export type ChangeSetResourceChangeDetail = RCD; + /** * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. */ @@ -35,7 +38,7 @@ export class TemplateAndChangeSetDiffMerger { }; } - static determineChangeSetReplacementMode(propertyChange: ResourceChangeDetail): types.ChangeSetReplacementMode { + static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ChangeSetReplacementMode { if (propertyChange.Target?.RequiresRecreation === undefined) { // We can't determine if the resource will be replaced or not. That's what conditionally means. return 'Conditionally'; From 82c7a574e61bb1ddc88842610221740bf532148c Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 08:30:45 -0400 Subject: [PATCH 21/36] add test for formatting --- packages/@aws-cdk/cloudformation-diff/lib/format.ts | 2 +- .../@aws-cdk/cloudformation-diff/test/format.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 packages/@aws-cdk/cloudformation-diff/test/format.test.ts diff --git a/packages/@aws-cdk/cloudformation-diff/lib/format.ts b/packages/@aws-cdk/cloudformation-diff/lib/format.ts index 45341b24c0730..61f44fc15e5fa 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/format.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/format.ts @@ -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 }, diff --git a/packages/@aws-cdk/cloudformation-diff/test/format.test.ts b/packages/@aws-cdk/cloudformation-diff/test/format.test.ts new file mode 100644 index 0000000000000..6cd6d23d38b5f --- /dev/null +++ b/packages/@aws-cdk/cloudformation-diff/test/format.test.ts @@ -0,0 +1,9 @@ +import * as chalk from 'chalk'; +import { Formatter } from '../lib'; + +const formatter = new Formatter(process.stdout, {}); + +test('format value can handle partial json strings', () => { + const output = formatter.formatValue({ nice: 'great', partialJson: '{"wow": "great' }, chalk.red); + expect(output).toEqual('{\"nice\":\"great\",\"partialJson\":\"{\\\"wow\\\": \\\"great\"}'); +}); \ No newline at end of file From 207c416ea40526d4db7c13bda9d752d6884e406e Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 14:09:36 -0400 Subject: [PATCH 22/36] fix describe changeset --- packages/aws-cdk/lib/api/util/cloudformation.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index 6d1f3140207b2..d2f46b12ad415 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -197,7 +197,9 @@ async function describeChangeSet( changeSetName: string, { fetchAll }: { fetchAll: boolean }, ): Promise { + const statusReason = (await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise()).StatusReason; const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true }).promise(); + response.StatusReason = statusReason; // As of now, describeChangeSet doesn't include the StatusReason if you specify IncludePropertyValues. // If fetchAll is true, traverse all pages from the change set description. while (fetchAll && response.NextToken != null) { From f12b22b8d262349c485c53716b28856f0a41afb4 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 16:05:47 -0400 Subject: [PATCH 23/36] better --- packages/aws-cdk/lib/api/util/cloudformation.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index d2f46b12ad415..6941803d59ac6 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -197,9 +197,15 @@ async function describeChangeSet( changeSetName: string, { fetchAll }: { fetchAll: boolean }, ): Promise { - const statusReason = (await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise()).StatusReason; - const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true }).promise(); - response.StatusReason = statusReason; // As of now, describeChangeSet doesn't include the StatusReason if you specify IncludePropertyValues. + const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise(); + const changesWithPropertyValues = (await cfn.describeChangeSet({ + StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true, + }).promise()) + .Changes; + + // As of now, describeChangeSet doesn't include the StatusReason if you specify IncludePropertyValues. + // So, make a call to get the Changes with property values and a call to get the other outputs. + response.Changes = changesWithPropertyValues; // If fetchAll is true, traverse all pages from the change set description. while (fetchAll && response.NextToken != null) { From 3e38f56afe61db842aba30a5d2da9dbfbfb30c3d Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 16:35:08 -0400 Subject: [PATCH 24/36] add private and public --- .../cloudformation-diff/lib/diff-template.ts | 2 +- .../lib/diff/template-and-changeset-diff-merger.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index e9dd5fd3fc2ca..52e70d94f9e6e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -57,7 +57,7 @@ export function fullDiff( let theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { // These methods mutate the state of theDiff, using the changeSet. - const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet }); changeSetDiff.addChangeSetResourcesToDiffResources(theDiff.resources); theDiff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => changeSetDiff.hydrateChangeImpactFromChangeSet(logicalId, change), diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 81b65272197ad..660c4220adecb 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -17,7 +17,7 @@ export class TemplateAndChangeSetDiffMerger { /** * @param parseOldOrNewValues These enum values come from DescribeChangeSetOutput, which indicate if the property resolves to that value after the change or before the change. */ - static convertResourceFromChangesetToResourceForDiff( + public static convertResourceFromChangesetToResourceForDiff( resourceInfoFromChangeset: types.ChangeSetResource, parseOldOrNewValues: 'BEFORE_VALUES' | 'AFTER_VALUES', ): types.Resource { @@ -38,7 +38,7 @@ export class TemplateAndChangeSetDiffMerger { }; } - static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ChangeSetReplacementMode { + public static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ChangeSetReplacementMode { if (propertyChange.Target?.RequiresRecreation === undefined) { // We can't determine if the resource will be replaced or not. That's what conditionally means. return 'Conditionally'; @@ -74,7 +74,7 @@ export class TemplateAndChangeSetDiffMerger { /** * use information from the changeset to populate details that are missing in the templateDiff */ - createChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { + private createChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { const changeSetResources: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { @@ -121,7 +121,7 @@ export class TemplateAndChangeSetDiffMerger { * - One case when this can happen is when a resource is added to the stack through the changeset. * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. */ - addChangeSetResourcesToDiffResources(resourceDiffs: types.DifferenceCollection) { + public addChangeSetResourcesToDiffResources(resourceDiffs: types.DifferenceCollection) { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { @@ -147,7 +147,7 @@ export class TemplateAndChangeSetDiffMerger { } } - hydrateChangeImpactFromChangeSet(logicalId: string, change: types.ResourceDifference) { + public hydrateChangeImpactFromChangeSet(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 @@ -189,7 +189,7 @@ export class TemplateAndChangeSetDiffMerger { }); } - addImportInformation(resourceDiffs: types.DifferenceCollection) { + public addImportInformation(resourceDiffs: types.DifferenceCollection) { const imports = this.findResourceImports(); resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { if (imports.includes(logicalId)) { @@ -198,7 +198,7 @@ export class TemplateAndChangeSetDiffMerger { }); } - findResourceImports(): string[] { + public findResourceImports(): string[] { const importedResourceLogicalIds = []; for (const resourceChange of this.changeSet?.Changes ?? []) { if (resourceChange.ResourceChange?.Action === 'Import') { From 9fcaadd27121bf1a83d0f0e702f4ff81419ef7b8 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 16:55:27 -0400 Subject: [PATCH 25/36] use enum and better UNKNOWN --- .../template-and-changeset-diff-merger.ts | 27 +++++++++++-------- ...template-and-changeset-diff-merger.test.ts | 20 +++++++------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 660c4220adecb..1aa05c8cb4662 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -5,24 +5,29 @@ import { diffResource } from '.'; import * as types from '../diff/types'; export type DescribeChangeSetOutput = DescribeChangeSet; -export type ChangeSetResourceChangeDetail = RCD; +type ChangeSetResourceChangeDetail = RCD; + +/** + * These values come from DescribeChangeSetOutput, which indicate if the property resolves to that value after the change or before the change. + */ +export enum BEFORE_OR_AFTER_VALUES { + Before = 'BEFORE_VALUES', + After = 'AFTER_VALUES', +} /** * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. */ export class TemplateAndChangeSetDiffMerger { // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. - static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN'; + static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN_RESOURCE_TYPE'; - /** - * @param parseOldOrNewValues These enum values come from DescribeChangeSetOutput, which indicate if the property resolves to that value after the change or before the change. - */ public static convertResourceFromChangesetToResourceForDiff( resourceInfoFromChangeset: types.ChangeSetResource, - parseOldOrNewValues: 'BEFORE_VALUES' | 'AFTER_VALUES', + parseOldOrNewValues: BEFORE_OR_AFTER_VALUES, ): types.Resource { const props: { [logicalId: string]: string | undefined } = {}; - if (parseOldOrNewValues === 'AFTER_VALUES') { + if (parseOldOrNewValues === BEFORE_OR_AFTER_VALUES.After) { for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { props[propertyName] = value.afterValue; } @@ -72,7 +77,7 @@ export class TemplateAndChangeSetDiffMerger { } /** - * use information from the changeset to populate details that are missing in the templateDiff + * Read resources from the changeSet, extracting information into ChangeSetResources. */ private createChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { const changeSetResources: types.ChangeSetResources = {}; @@ -94,7 +99,7 @@ export class TemplateAndChangeSetDiffMerger { changeSetResources[resourceChange.ResourceChange.LogicalResourceId] = { resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True', - resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChanegSet doesn't promise to have the ResourceType... + resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChangeSet doesn't promise to have the ResourceType... properties: propertiesReplaced, }; } @@ -126,8 +131,8 @@ export class TemplateAndChangeSetDiffMerger { const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { const resourceDiffFromChangeset = diffResource( - TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'BEFORE_VALUES'), - TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, 'AFTER_VALUES'), + TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, BEFORE_OR_AFTER_VALUES.Before), + TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, BEFORE_OR_AFTER_VALUES.After), ); resourceDiffs.set(logicalId, resourceDiffFromChangeset); } diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index d4b4a0a45a315..7775f56990701 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,7 +1,7 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges } from './util'; import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, ChangeSetResource, DifferenceCollection, Resource } from '../lib'; -import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; +import { BEFORE_OR_AFTER_VALUES, TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; describe('fullDiff tests that include changeset', () => { test('changeset overrides spec replacements', () => { @@ -1106,7 +1106,7 @@ describe('method tests', () => { }); expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ resourceWasReplaced: true, - resourceType: 'UNKNOWN', + resourceType: 'UNKNOWN_RESOURCE_TYPE', properties: { QueueName: { changeSetReplacementMode: 'Always', @@ -1126,7 +1126,7 @@ describe('method tests', () => { expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(1); expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ resourceWasReplaced: true, - resourceType: 'UNKNOWN', + resourceType: 'UNKNOWN_RESOURCE_TYPE', properties: {}, }); }); @@ -1210,21 +1210,21 @@ describe('method tests', () => { // WHEN const resourceAfterChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( changeSetResource, - 'AFTER_VALUES', + BEFORE_OR_AFTER_VALUES.After, ); const resourceBeforeChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( changeSetResource, - 'BEFORE_VALUES', + BEFORE_OR_AFTER_VALUES.Before, ); // THEN expect(resourceBeforeChange).toEqual({ - Type: 'UNKNOWN', + Type: 'UNKNOWN_RESOURCE_TYPE', Properties: {}, }); expect(resourceAfterChange).toEqual({ - Type: 'UNKNOWN', + Type: 'UNKNOWN_RESOURCE_TYPE', Properties: {}, }); }); @@ -1246,11 +1246,11 @@ describe('method tests', () => { // WHEN const resourceAfterChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( changeSetResource, - 'AFTER_VALUES', + BEFORE_OR_AFTER_VALUES.After, ); const resourceBeforeChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( changeSetResource, - 'BEFORE_VALUES', + BEFORE_OR_AFTER_VALUES.Before, ); // THEN @@ -1392,7 +1392,7 @@ describe('method tests', () => { expect(resources.differenceCount).toBe(1); expect(resources.changes.Queue.isUpdate).toBe(true); expect(resources.changes.Queue.oldValue).toEqual({ - Type: 'UNKNOWN', + Type: 'UNKNOWN_RESOURCE_TYPE', Properties: { QueueName: undefined }, }); expect(resources.changes.Queue.oldValue).toEqual(resources.changes.Queue.newValue); From 94cbbda139c30f7b8022c9f4ac9586ee18d269f9 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Mon, 20 May 2024 18:02:52 -0400 Subject: [PATCH 26/36] include property values --- .../template-and-changeset-diff-merger.ts | 27 +++-- .../cloudformation-diff/lib/diff/types.ts | 6 ++ ...template-and-changeset-diff-merger.test.ts | 99 +++++++++++++++++++ 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 1aa05c8cb4662..3a5385bc67f08 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -128,12 +128,18 @@ export class TemplateAndChangeSetDiffMerger { */ public addChangeSetResourcesToDiffResources(resourceDiffs: types.DifferenceCollection) { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { + const oldResource = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( + changeSetResource, + BEFORE_OR_AFTER_VALUES.Before, + ); + const newResource = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( + changeSetResource, + BEFORE_OR_AFTER_VALUES.After, + ); + const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { - const resourceDiffFromChangeset = diffResource( - TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, BEFORE_OR_AFTER_VALUES.Before), - TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff(changeSetResource, BEFORE_OR_AFTER_VALUES.After), - ); + const resourceDiffFromChangeset = diffResource(oldResource, newResource); resourceDiffs.set(logicalId, resourceDiffFromChangeset); } @@ -141,13 +147,18 @@ export class TemplateAndChangeSetDiffMerger { for (const propertyName of Object.keys(this.changeSetResources[logicalId].properties ?? {})) { if (propertyName in propertyChangesFromTemplate) { // If the property is already marked to be updated, then we don't need to do anything. + // But in a future change, we could think about always overwriting the template change with what the ChangeSet has, since the ChangeSet diff may be more accurate. continue; } - // This property diff will be hydrated when hydrateChangeImpactFromChangeSet is called. - const emptyPropertyDiff = new types.PropertyDifference({}, {}, {}); - emptyPropertyDiff.isDifferent = true; - resourceDiffs.get(logicalId).setPropertyChange(propertyName, emptyPropertyDiff); + const emptyChangeImpact = {}; + const propertyDiff = new types.PropertyDifference( + oldResource.Properties?.[propertyName] ?? {}, + newResource.Properties?.[propertyName] ?? {}, + emptyChangeImpact, // changeImpact will be hydrated when hydrateChangeImpactFromChangeSet is called. + ); + propertyDiff.isDifferent = true; + resourceDiffs.get(logicalId).setPropertyChange(propertyName, propertyDiff); } } } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 09a3795fb9b62..53ebbd8878d75 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -380,7 +380,13 @@ export class DifferenceCollection> { delete this.diffs[logicalId]; } + /** + * Throw an error to prevent the caller from overwriting a change. + */ public set(logicalId: string, diff: T): void { + if (this.diffs[logicalId]) { + throw new Error(`LogicalId already exists in this DifferenceCollection. LogicalId: '${logicalId}'`); + } this.diffs[logicalId] = diff; } diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 7775f56990701..945b2f74beb79 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1265,6 +1265,83 @@ describe('method tests', () => { }); }); + test('addChangeSetResourcesToDiff can add resources that have template changes and changeset changes', async () => { + // GIVEN + const resources = new DifferenceCollection( + { + Queue: new ResourceDifference( + { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'first' } }, + { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::SQS::QUEUE', newType: 'AWS::SQS::QUEUE' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ), + }, + ); + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + properties: { + DelaySeconds: { + changeSetReplacementMode: 'Conditionally', + afterValue: 10, + beforeValue: 2, + }, + }, + } as any, + }, + }); + + //WHEN + templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); + + // THEN + expect(resources.differenceCount).toBe(1); + expect(resources.changes.Queue.isUpdate).toBe(true); + expect(resources.changes.Queue).toEqual({ + oldValue: { + Type: 'AWS::SQS::QUEUE', + Properties: { + QueueName: 'first', + }, + }, + newValue: { + Type: 'AWS::SQS::QUEUE', + Properties: { + QueueName: 'second', + }, + }, + resourceTypes: { + oldType: 'AWS::SQS::QUEUE', + newType: 'AWS::SQS::QUEUE', + }, + propertyDiffs: { + QueueName: { + oldValue: 'first', + newValue: 'second', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + DelaySeconds: { + oldValue: 2, + newValue: 10, + isDifferent: true, + changeImpact: undefined, + }, + }, + otherDiffs: { + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); + + }); + test('addChangeSetResourcesToDiff can add resources from changeset', async () => { // GIVEN const resources = new DifferenceCollection({}); @@ -1671,4 +1748,26 @@ describe('method tests', () => { expect(queue.isDifferent).toBe(true); }); + test('it is an error to DifferenceCollection.set if a given logicalId is already in the collection', async () => { + // WHEN + const logicalId = 'NewAndUnseen1111'; + const resourceDiff = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference('first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }); + const diffColl = new DifferenceCollection({ + [logicalId]: resourceDiff, + }); + + // THEN + expect(() => { + diffColl.set(logicalId, resourceDiff); + }).toThrow(`LogicalId already exists in this DifferenceCollection. LogicalId: '${logicalId}'`); + + }); + }); From f23ce3dee1539b1e4da7dcf2a01fe755add87901 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Tue, 21 May 2024 08:40:44 -0400 Subject: [PATCH 27/36] now use beforeContext and afterContext to see property changes --- .../template-and-changeset-diff-merger.ts | 68 +++++------------- .../cloudformation-diff/lib/diff/types.ts | 10 ++- ...template-and-changeset-diff-merger.test.ts | 70 +------------------ 3 files changed, 28 insertions(+), 120 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 3a5385bc67f08..65323eddd03b4 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -7,14 +7,6 @@ import * as types from '../diff/types'; export type DescribeChangeSetOutput = DescribeChangeSet; type ChangeSetResourceChangeDetail = RCD; -/** - * These values come from DescribeChangeSetOutput, which indicate if the property resolves to that value after the change or before the change. - */ -export enum BEFORE_OR_AFTER_VALUES { - Before = 'BEFORE_VALUES', - After = 'AFTER_VALUES', -} - /** * The purpose of this class is to include differences from the ChangeSet to differences in the TemplateDiff. */ @@ -22,27 +14,6 @@ export class TemplateAndChangeSetDiffMerger { // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN_RESOURCE_TYPE'; - public static convertResourceFromChangesetToResourceForDiff( - resourceInfoFromChangeset: types.ChangeSetResource, - parseOldOrNewValues: BEFORE_OR_AFTER_VALUES, - ): types.Resource { - const props: { [logicalId: string]: string | undefined } = {}; - if (parseOldOrNewValues === BEFORE_OR_AFTER_VALUES.After) { - for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { - props[propertyName] = value.afterValue; - } - } else { - for (const [propertyName, value] of Object.entries(resourceInfoFromChangeset.properties ?? {})) { - props[propertyName] = value.beforeValue; - } - } - - return { - Type: resourceInfoFromChangeset.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: props, - }; - } - public static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ChangeSetReplacementMode { if (propertyChange.Target?.RequiresRecreation === undefined) { // We can't determine if the resource will be replaced or not. That's what conditionally means. @@ -91,8 +62,6 @@ export class TemplateAndChangeSetDiffMerger { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { propertiesReplaced[propertyChange.Target.Name] = { changeSetReplacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), - beforeValue: _maybeJsonParse(propertyChange.Target.BeforeValue), - afterValue: _maybeJsonParse(propertyChange.Target.AfterValue), }; } } @@ -101,14 +70,18 @@ export class TemplateAndChangeSetDiffMerger { resourceWasReplaced: resourceChange.ResourceChange.Replacement === 'True', resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChangeSet doesn't promise to have the ResourceType... properties: propertiesReplaced, + beforeContext: _maybeJsonParse(resourceChange.ResourceChange.BeforeContext), + afterContext: _maybeJsonParse(resourceChange.ResourceChange.AfterContext), }; } return changeSetResources; /** - * we will try to parse the afterValue so that downstream processing of the diff can access object properties. - * However, there's not a guarantee that it will work, since clouformation will truncate the afterValue and BeforeValue if they're too long. + * we will try to parse the BeforeContext and AfterContext so that downstream processing of the diff can access object properties. + * + * This should always succeed. But CFN says they truncate the beforeValue and afterValue if they're too long, and since the afterValue and beforeValue + * are a subset of the BeforeContext and AfterContext, it seems safer to assume that BeforeContext and AfterContext also may truncate. */ function _maybeJsonParse(value: string | undefined): any | undefined { try { @@ -128,18 +101,18 @@ export class TemplateAndChangeSetDiffMerger { */ public addChangeSetResourcesToDiffResources(resourceDiffs: types.DifferenceCollection) { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { - const oldResource = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( - changeSetResource, - BEFORE_OR_AFTER_VALUES.Before, - ); - const newResource = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( - changeSetResource, - BEFORE_OR_AFTER_VALUES.After, - ); + const oldResource: types.Resource = { + Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + Properties: changeSetResource.beforeContext?.Properties, + }; + const newResource: types.Resource = { + Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + Properties: changeSetResource.afterContext?.Properties, + }; + const resourceDiffFromChangeset = diffResource(oldResource, newResource); const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); if (resourceNotFoundInTemplateDiff) { - const resourceDiffFromChangeset = diffResource(oldResource, newResource); resourceDiffs.set(logicalId, resourceDiffFromChangeset); } @@ -151,14 +124,9 @@ export class TemplateAndChangeSetDiffMerger { continue; } - const emptyChangeImpact = {}; - const propertyDiff = new types.PropertyDifference( - oldResource.Properties?.[propertyName] ?? {}, - newResource.Properties?.[propertyName] ?? {}, - emptyChangeImpact, // changeImpact will be hydrated when hydrateChangeImpactFromChangeSet is called. - ); - propertyDiff.isDifferent = true; - resourceDiffs.get(logicalId).setPropertyChange(propertyName, propertyDiff); + if (resourceDiffFromChangeset.propertyUpdates?.[propertyName]) { + resourceDiffs.get(logicalId).setPropertyChange(propertyName, resourceDiffFromChangeset.propertyUpdates?.[propertyName]); + } } } } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 53ebbd8878d75..3600476fb98cd 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -8,17 +8,23 @@ export type PropertyMap = {[key: string]: any }; export type ChangeSetResources = { [logicalId: string]: ChangeSetResource }; +/** + * @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. + * + * @param afterContext same as beforeContext but for after the change is made. + */ export interface ChangeSetResource { resourceWasReplaced: boolean; resourceType: string | undefined; properties: ChangeSetProperties | undefined; + beforeContext: any; + afterContext: any; } export type ChangeSetProperties = { [propertyName: string]: { changeSetReplacementMode: ChangeSetReplacementMode | undefined; - beforeValue: string | undefined; - afterValue: string | undefined; }; } diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 945b2f74beb79..d41ca84db8b67 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,7 +1,7 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges } from './util'; -import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, ChangeSetResource, DifferenceCollection, Resource } from '../lib'; -import { BEFORE_OR_AFTER_VALUES, TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; +import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, DifferenceCollection, Resource } from '../lib'; +import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; describe('fullDiff tests that include changeset', () => { test('changeset overrides spec replacements', () => { @@ -1199,72 +1199,6 @@ describe('method tests', () => { expect(changeSetReplacementMode).toEqual('Always'); }); - test('convertResourceFromChangesetToResourceForDiff with missing resourceType and properties', async () => { - // GIVEN - const changeSetResource: ChangeSetResource = { - resourceWasReplaced: false, - resourceType: undefined, - properties: undefined, - }; - - // WHEN - const resourceAfterChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( - changeSetResource, - BEFORE_OR_AFTER_VALUES.After, - ); - const resourceBeforeChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( - changeSetResource, - BEFORE_OR_AFTER_VALUES.Before, - ); - - // THEN - expect(resourceBeforeChange).toEqual({ - Type: 'UNKNOWN_RESOURCE_TYPE', - Properties: {}, - }); - - expect(resourceAfterChange).toEqual({ - Type: 'UNKNOWN_RESOURCE_TYPE', - Properties: {}, - }); - }); - - test('convertResourceFromChangesetToResourceForDiff with fully filled input', async () => { - // GIVEN - const changeSetResource: ChangeSetResource = { - resourceWasReplaced: false, - resourceType: 'CDK::IS::GREAT', - properties: { - C: { - changeSetReplacementMode: 'Always', - beforeValue: 'changedddd', - afterValue: 'sdflkja', - }, - }, - }; - - // WHEN - const resourceAfterChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( - changeSetResource, - BEFORE_OR_AFTER_VALUES.After, - ); - const resourceBeforeChange = TemplateAndChangeSetDiffMerger.convertResourceFromChangesetToResourceForDiff( - changeSetResource, - BEFORE_OR_AFTER_VALUES.Before, - ); - - // THEN - expect(resourceBeforeChange).toEqual({ - Type: 'CDK::IS::GREAT', - Properties: { C: 'changedddd' }, - }); - - expect(resourceAfterChange).toEqual({ - Type: 'CDK::IS::GREAT', - Properties: { C: 'sdflkja' }, - }); - }); - test('addChangeSetResourcesToDiff can add resources that have template changes and changeset changes', async () => { // GIVEN const resources = new DifferenceCollection( From 61bbcce0c1d91ce9e2040723a60e45c2f5ebb3c2 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Tue, 21 May 2024 09:26:04 -0400 Subject: [PATCH 28/36] integ test for security changes --- .../cli-integ/lib/integ-test.ts | 10 +++ .../cli-integ/resources/cdk-apps/app/app.js | 24 +++++++ .../tests/cli-integ-tests/cli.integtest.ts | 70 ++++++++++++++++--- 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts b/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts index b4655461b5f57..e0d277bcbea6d 100644 --- a/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts +++ b/packages/@aws-cdk-testing/cli-integ/lib/integ-test.ts @@ -79,3 +79,13 @@ export function randomString() { // Crazy return Math.random().toString(36).replace(/[^a-z0-9]+/g, ''); } + +export function normalizeDiffOutput(s: string, removeFormatting: boolean = false): string { + if (removeFormatting) { + // remove all color and formatting (bolding, italic, etc) + s = s.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + } + + return s.replace(/ /g, '') // remove all spaces + .replace(/\n/g, ''); // remove all new lines +} diff --git a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js index f68e0002b28a6..03362719c4f8d 100755 --- a/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js +++ b/packages/@aws-cdk-testing/cli-integ/resources/cdk-apps/app/app.js @@ -530,6 +530,29 @@ class DockerStackWithCustomFile extends cdk.Stack { }); } } +class SecurityDiffFromChangeSetStack extends Stack { + constructor(scope, id) { + super(scope, id); + + const iamResourceName = ssm.StringParameter.valueForStringParameter(this, 'for-iam-role-defined-by-ssm-param'); + + new iam.Role(this, 'changeSetDiffIamRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + inlinePolicies: { + fun: new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.DENY, + actions: ['sqs:*'], + resources: [`arn:aws:sqs:us-east-1:444455556666:${iamResourceName}`], + }), + ], + }), + }, + }); + + } +} class DiffFromChangeSetStack extends Stack { constructor(scope, id) { @@ -702,6 +725,7 @@ switch (stackSet) { const failed = new FailedStack(app, `${stackPrefix}-failed`) new DiffFromChangeSetStack(app, `${stackPrefix}-queue-name-defined-by-ssm-param`) + new SecurityDiffFromChangeSetStack(app, `${stackPrefix}-iam-role-defined-by-ssm-param`) // A stack that depends on the failed stack -- used to test that '-e' does not deploy the failing stack const dependsOnFailed = new OutputsStack(app, `${stackPrefix}-depends-on-failed`); diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index eeaac1c479898..dbd762442f63a 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -1,7 +1,7 @@ import { promises as fs, existsSync } from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { integTest, cloneDirectory, shell, withDefaultFixture, retry, sleep, randomInteger, withSamIntegrationFixture, RESOURCES_DIR, withCDKMigrateFixture, withExtendedTimeoutFixture, randomString } from '../../lib'; +import { integTest, cloneDirectory, shell, withDefaultFixture, retry, sleep, randomInteger, withSamIntegrationFixture, RESOURCES_DIR, withCDKMigrateFixture, withExtendedTimeoutFixture, randomString, normalizeDiffOutput } from '../../lib'; jest.setTimeout(2 * 60 * 60_000); // Includes the time to acquire locks, worst-case single-threaded runtime @@ -944,6 +944,62 @@ integTest('cdk diff --quiet does not print \'There were no differences\' message expect(diff).not.toContain('There were no differences'); })); +integTest('cdk diff picks up security changes that are only in changeset', withDefaultFixture(async (fixture) => { + // GIVEN + const originalRoleName = randomString(); + await fixture.aws.ssm('putParameter', { + Name: 'for-iam-role-defined-by-ssm-param', + Value: originalRoleName, + Type: 'String', + Overwrite: true, + }); + + try { + await fixture.cdkDeploy('iam-role-defined-by-ssm-param'); + + // WHEN + // We want to change the ssm value. Then the CFN changeset will detect that the queue will be changed upon deploy. + const newRoleName = randomString(); + await fixture.aws.ssm('putParameter', { + Name: 'for-iam-role-defined-by-ssm-param', + Value: newRoleName, + Type: 'String', + Overwrite: true, + }); + + const diff = await fixture.cdk(['diff', fixture.fullStackName('iam-role-defined-by-ssm-param')]); + + // THEN + const normalizedPlainTextOutput = normalizeDiffOutput(diff, true); + + const title = normalizeDiffOutput('IAM Statement Changes'); + const header = normalizeDiffOutput('│ │ Resource │ Effect │ Action │ Principal │ Condition │ '); + const remove = normalizeDiffOutput(`│ - │ arn:aws:sqs:us-east-1:444455556666:${originalRoleName} │ Deny │ sqs:* │ AWS:\${changeSetDiffIamRole} │ │ `); + const add = normalizeDiffOutput(` │ + │ arn:aws:sqs:us-east-1:444455556666:${newRoleName} │ Deny │ sqs:* │ AWS:\${changeSetDiffIamRole} │ │ `); + const expectedDiff = normalizeDiffOutput(` + Resources + [~] AWS::IAM::Role changeSetDiffIamRole changeSetDiffIamRole9C803BA2 + └─ [~] Policies + └─ @@ -6,7 +6,7 @@ + [ ] "Statement": [ + [ ] { + [ ] "Action": "sqs:*", + [-] "Resource": "arn:aws:sqs:us-east-1:444455556666:${originalRoleName}", + [+] "Resource": "arn:aws:sqs:us-east-1:444455556666:${newRoleName}", + [ ] "Effect": "Deny" + [ ] } + [ ] ]`); + + expect(normalizedPlainTextOutput).toContain(expectedDiff); + expect(normalizedPlainTextOutput).toContain(title); + expect(normalizedPlainTextOutput).toContain(header); + expect(normalizedPlainTextOutput).toContain(remove); + expect(normalizedPlainTextOutput).toContain(add); + } finally { + await fixture.cdkDestroy('iam-role-defined-by-ssm-param'); + } +})); + integTest('cdk diff picks up changes that are only present in changeset', withDefaultFixture(async (fixture) => { // GIVEN const originalQueueName = randomString(); @@ -970,12 +1026,9 @@ integTest('cdk diff picks up changes that are only present in changeset', withDe const diff = await fixture.cdk(['diff', fixture.fullStackName('queue-name-defined-by-ssm-param')]); // THEN - const normalizedPlainTextOutput = diff.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') // remove all color and formatting (bolding, italic, etc) - .replace(/ /g, '') // remove all spaces - .replace(/\n/g, '') // remove all new lines - .replace(/\d+/g, ''); // remove all digits + const normalizedPlainTextOutput = normalizeDiffOutput(diff, true); - const normalizedExpectedOutput = ` + const normalizedExpectedOutput = normalizeDiffOutput(` Resources [~] AWS::SQS::Queue DiffFromChangeSetQueue DiffFromChangeSetQueue06622C07 replace └─ [~] QueueName (requires replacement) @@ -984,10 +1037,7 @@ integTest('cdk diff picks up changes that are only present in changeset', withDe [~] AWS::SSM::Parameter DiffFromChangeSetSSMParam DiffFromChangeSetSSMParam92A9A723 └─ [~] Value ├─ [-] ${originalQueueName} - └─ [+] ${newQueueName}` - .replace(/ /g, '') - .replace(/\n/g, '') - .replace(/\d+/g, ''); + └─ [+] ${newQueueName}`); expect(normalizedPlainTextOutput).toContain(normalizedExpectedOutput); } finally { From a06fc7be3aa00826982b23f5f4d10e4ba9da923d Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Wed, 22 May 2024 11:05:15 -0400 Subject: [PATCH 29/36] fixing unit tests --- .../cloudformation-diff/lib/diff-template.ts | 8 +- .../template-and-changeset-diff-merger.ts | 53 +- .../cloudformation-diff/lib/diff/types.ts | 18 +- ...template-and-changeset-diff-merger.test.ts | 1288 ++++++++--------- .../@aws-cdk/cloudformation-diff/test/util.ts | 208 ++- .../aws-cdk/lib/api/util/cloudformation.ts | 29 +- 6 files changed, 767 insertions(+), 837 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 52e70d94f9e6e..76b7509b109e8 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -54,15 +54,15 @@ export function fullDiff( normalize(currentTemplate); normalize(newTemplate); - let theDiff = diffTemplate(currentTemplate, newTemplate); + let theDiff = diffTemplate(currentTemplate, newTemplate); // I could remove this step and then run the integ tests and see what happens, assuming those tests use changeset if (changeSet) { // These methods mutate the state of theDiff, using the changeSet. const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet }); - changeSetDiff.addChangeSetResourcesToDiffResources(theDiff.resources); + changeSetDiff.overrideDiffResourcesWithChangeSetResources(theDiff.resources); theDiff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => - changeSetDiff.hydrateChangeImpactFromChangeSet(logicalId, change), + changeSetDiff.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, change), ); - changeSetDiff.addImportInformation(theDiff.resources); + changeSetDiff.addImportInformationFromChangeset(theDiff.resources); theDiff = new types.TemplateDiff(theDiff); // do this to propagate security changes. } else if (isImport) { makeAllResourceChangesImports(theDiff); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 65323eddd03b4..c8d4537dcb77e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -14,7 +14,7 @@ export class TemplateAndChangeSetDiffMerger { // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN_RESOURCE_TYPE'; - public static determineChangeSetReplacementMode(propertyChange: ChangeSetResourceChangeDetail): types.ChangeSetReplacementMode { + 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'; @@ -31,7 +31,7 @@ export class TemplateAndChangeSetDiffMerger { } } - return propertyChange.Target.RequiresRecreation as types.ChangeSetReplacementMode; + return propertyChange.Target.RequiresRecreation as types.ReplacementModes; } changeSet: DescribeChangeSetOutput | undefined; @@ -44,24 +44,24 @@ export class TemplateAndChangeSetDiffMerger { }, ) { this.changeSet = args.changeSet; - this.changeSetResources = args.changeSetResources ?? this.createChangeSetResources(this.changeSet); + this.changeSetResources = args.changeSetResources ?? this.convertDescribeChangeSetOutputToChangeSetResources(this.changeSet); } /** * Read resources from the changeSet, extracting information into ChangeSetResources. */ - private createChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { + private convertDescribeChangeSetOutputToChangeSetResources(changeSet: DescribeChangeSetOutput): types.ChangeSetResources { const changeSetResources: types.ChangeSetResources = {}; for (const resourceChange of changeSet.Changes ?? []) { if (resourceChange.ResourceChange?.LogicalResourceId === undefined) { - continue; + continue; // Being defensive, here. } - const propertiesReplaced: types.ChangeSetProperties = {}; + const propertyReplacementModes: types.PropertyReplacementModeMap = {}; for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { - propertiesReplaced[propertyChange.Target.Name] = { - changeSetReplacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), + propertyReplacementModes[propertyChange.Target.Name] = { + replacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), }; } } @@ -69,7 +69,7 @@ export class TemplateAndChangeSetDiffMerger { 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... - properties: propertiesReplaced, + propertyReplacementModes: propertyReplacementModes, beforeContext: _maybeJsonParse(resourceChange.ResourceChange.BeforeContext), afterContext: _maybeJsonParse(resourceChange.ResourceChange.AfterContext), }; @@ -93,13 +93,10 @@ export class TemplateAndChangeSetDiffMerger { } /** - * Finds resource differences that are only visible in the changeset diff from CloudFormation (that is, we can't find this difference in the diff between 2 templates) - * and adds those missing differences to the templateDiff. - * - * - One case when this can happen is when a resource is added to the stack through the changeset. - * - Another case is when a resource is changed because the resource is defined by an SSM parameter, and the value of that SSM parameter changes. + * Overwrites the resource diff that was computed between the new and old template with the diff of the resources from the ChangeSet. + * This is a more accurate way of computing the resource differences, since now cdk diff is reporting directly what the ChangeSet will apply. */ - public addChangeSetResourcesToDiffResources(resourceDiffs: types.DifferenceCollection) { + public overrideDiffResourcesWithChangeSetResources(resourceDiffs: types.DifferenceCollection) { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const oldResource: types.Resource = { Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, @@ -111,27 +108,11 @@ export class TemplateAndChangeSetDiffMerger { }; const resourceDiffFromChangeset = diffResource(oldResource, newResource); - const resourceNotFoundInTemplateDiff = !(resourceDiffs.logicalIds.includes(logicalId)); - if (resourceNotFoundInTemplateDiff) { - resourceDiffs.set(logicalId, resourceDiffFromChangeset); - } - - const propertyChangesFromTemplate = resourceDiffs.get(logicalId).propertyUpdates; - for (const propertyName of Object.keys(this.changeSetResources[logicalId].properties ?? {})) { - if (propertyName in propertyChangesFromTemplate) { - // If the property is already marked to be updated, then we don't need to do anything. - // But in a future change, we could think about always overwriting the template change with what the ChangeSet has, since the ChangeSet diff may be more accurate. - continue; - } - - if (resourceDiffFromChangeset.propertyUpdates?.[propertyName]) { - resourceDiffs.get(logicalId).setPropertyChange(propertyName, resourceDiffFromChangeset.propertyUpdates?.[propertyName]); - } - } + resourceDiffs.set(logicalId, resourceDiffFromChangeset); } } - public hydrateChangeImpactFromChangeSet(logicalId: string, change: types.ResourceDifference) { + 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 @@ -145,8 +126,8 @@ export class TemplateAndChangeSetDiffMerger { return; } - const changeSetReplacementMode = (this.changeSetResources[logicalId].properties ?? {})[name]?.changeSetReplacementMode; - switch (changeSetReplacementMode) { + const changingPropertyCausesResourceReplacement = (this.changeSetResources[logicalId].propertyReplacementModes ?? {})[name]?.replacementMode; + switch (changingPropertyCausesResourceReplacement) { case 'Always': (value as types.PropertyDifference).changeImpact = types.ResourceImpact.WILL_REPLACE; break; @@ -173,7 +154,7 @@ export class TemplateAndChangeSetDiffMerger { }); } - public addImportInformation(resourceDiffs: types.DifferenceCollection) { + public addImportInformationFromChangeset(resourceDiffs: types.DifferenceCollection) { const imports = this.findResourceImports(); resourceDiffs.forEachDifference((logicalId: string, change: types.ResourceDifference) => { if (imports.includes(logicalId)) { diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 3600476fb98cd..ee239e40aed50 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -17,18 +17,21 @@ export type ChangeSetResources = { [logicalId: string]: ChangeSetResource }; export interface ChangeSetResource { resourceWasReplaced: boolean; resourceType: string | undefined; - properties: ChangeSetProperties | undefined; - beforeContext: any; - afterContext: any; + propertyReplacementModes: PropertyReplacementModeMap | undefined; + beforeContext: any | undefined; + afterContext: any | undefined; } -export type ChangeSetProperties = { +export type PropertyReplacementModeMap = { [propertyName: string]: { - changeSetReplacementMode: ChangeSetReplacementMode | undefined; + replacementMode: ReplacementModes | undefined; }; } -export type ChangeSetReplacementMode = 'Always' | 'Never' | 'Conditionally'; +/** + * '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 { @@ -390,9 +393,6 @@ export class DifferenceCollection> { * Throw an error to prevent the caller from overwriting a change. */ public set(logicalId: string, diff: T): void { - if (this.diffs[logicalId]) { - throw new Error(`LogicalId already exists in this DifferenceCollection. LogicalId: '${logicalId}'`); - } this.diffs[logicalId] = diff; } diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index d41ca84db8b67..e66be851555b2 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,5 +1,5 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; -import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges } from './util'; +import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges, ssmParamFromChangeset, queueFromChangeset } from './util'; import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, DifferenceCollection, Resource } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; @@ -627,43 +627,7 @@ describe('fullDiff tests that include changeset', () => { // WHEN const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, - { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'mySsmParameter', - PhysicalResourceId: 'mySsmParameterFromStack', - ResourceType: 'AWS::SSM::Parameter', - Replacement: 'False', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'Value', - RequiresRecreation: 'Never', - Path: '/Properties/Value', - BeforeValue: 'changedddd', - AfterValue: 'sdflkja', - AttributeChangeType: 'Modify', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - Parameters: [{ - ParameterKey: 'SsmParameterValuetestbugreportC9', - ParameterValue: 'goodJob', - ResolvedValue: 'changedVal', - }], - }, + { Changes: [ssmParamFromChangeset] }, ); // THEN @@ -677,12 +641,16 @@ describe('fullDiff tests that include changeset', () => { Type: 'AWS::SSM::Parameter', Properties: { Value: 'changedddd', + Type: 'String', + Name: 'mySsmParameterFromStack', }, }, newValue: { Type: 'AWS::SSM::Parameter', Properties: { Value: 'sdflkja', + Type: 'String', + Name: 'mySsmParameterFromStack', }, }, resourceTypes: { @@ -696,6 +664,18 @@ describe('fullDiff tests that include changeset', () => { isDifferent: true, changeImpact: 'WILL_UPDATE', }, + Type: { + oldValue: 'String', + newValue: 'String', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + Name: { + oldValue: 'mySsmParameterFromStack', + newValue: 'mySsmParameterFromStack', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, }, otherDiffs: { Type: { @@ -739,35 +719,7 @@ describe('fullDiff tests that include changeset', () => { const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, { Changes: [ - { - Type: 'Resource', - ResourceChange: { - PolicyAction: 'ReplaceAndDelete', - Action: 'Modify', - LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', - ResourceType: 'AWS::SQS::Queue', - Replacement: 'True', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'QueueName', - RequiresRecreation: 'Always', - Path: '/Properties/QueueName', - BeforeValue: 'newValuechangedddd', - AfterValue: 'newValuesdflkja', - AttributeChangeType: 'Modify', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, + queueFromChangeset({}), ], Parameters: [{ ParameterKey: 'SsmParameterValuetestbugreportC9', @@ -788,12 +740,14 @@ describe('fullDiff tests that include changeset', () => { Type: 'AWS::SQS::Queue', Properties: { QueueName: 'newValuechangedddd', + ReceiveMessageWaitTimeSeconds: '20', }, }, newValue: { Type: 'AWS::SQS::Queue', Properties: { QueueName: 'newValuesdflkja', + ReceiveMessageWaitTimeSeconds: '20', }, }, resourceTypes: { @@ -807,6 +761,12 @@ describe('fullDiff tests that include changeset', () => { isDifferent: true, changeImpact: 'WILL_REPLACE', }, + ReceiveMessageWaitTimeSeconds: { + oldValue: '20', + newValue: '20', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, }, otherDiffs: { Type: { @@ -836,7 +796,7 @@ describe('fullDiff tests that include changeset', () => { }, }, Resources: { - Queue: sqsQueueWithAargs({ waitTime: 10 }), + Queue: sqsQueueWithAargs({ waitTime: 10, queueName: 'hi' }), }, }; @@ -848,7 +808,7 @@ describe('fullDiff tests that include changeset', () => { }, }, Resources: { - Queue: sqsQueueWithAargs({ waitTime: 20 }), + Queue: sqsQueueWithAargs({ waitTime: 10, queueName: 'bye' }), }, }; @@ -856,32 +816,7 @@ describe('fullDiff tests that include changeset', () => { const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - PolicyAction: 'ReplaceAndDelete', - Action: 'Modify', - LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValueNEEEWWWEEERRRRR', - ResourceType: 'AWS::SQS::Queue', - Replacement: 'True', - Scope: [ - 'Properties', - ], - Details: [{ - Target: { Attribute: 'Properties', Name: 'QueueName', RequiresRecreation: 'Always' }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - { - Target: { Attribute: 'Properties', Name: 'ReceiveMessageWaitTimeSeconds', RequiresRecreation: 'Never' }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }], - }, - }, - ], + Changes: [queueFromChangeset({ beforeContextWaitTime: '10', afterContextWaitTime: '20' })], Parameters: [{ ParameterKey: 'SsmParameterValuetestbugreportC9', ParameterValue: 'goodJob', @@ -895,8 +830,8 @@ describe('fullDiff tests that include changeset', () => { expect(diffWithoutChangeSet.resources.changes).toEqual( { Queue: { - oldValue: sqsQueueWithAargs({ waitTime: 10 }), - newValue: sqsQueueWithAargs({ waitTime: 20 }), + oldValue: sqsQueueWithAargs({ waitTime: 10, queueName: 'hi' }), + newValue: sqsQueueWithAargs({ waitTime: 10, queueName: 'bye' }), resourceTypes: { oldType: 'AWS::SQS::Queue', newType: 'AWS::SQS::Queue', @@ -904,19 +839,19 @@ describe('fullDiff tests that include changeset', () => { propertyDiffs: { QueueName: { oldValue: { - Ref: 'SsmParameterValuetestbugreportC9', + Ref: 'hi', }, newValue: { - Ref: 'SsmParameterValuetestbugreportC9', + Ref: 'bye', }, - isDifferent: false, - changeImpact: 'NO_CHANGE', + isDifferent: true, + changeImpact: 'WILL_REPLACE', }, ReceiveMessageWaitTimeSeconds: { oldValue: 10, - newValue: 20, - isDifferent: true, - changeImpact: 'WILL_UPDATE', + newValue: 10, + isDifferent: false, + changeImpact: 'NO_CHANGE', }, }, otherDiffs: { @@ -934,42 +869,58 @@ describe('fullDiff tests that include changeset', () => { ); expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed - expect(diffWithChangeSet.resources.changes).toEqual( + expect(diffWithChangeSet.resources.changes.Queue).toEqual( { - Queue: { - oldValue: sqsQueueWithAargs({ waitTime: 10 }), - newValue: sqsQueueWithAargs({ waitTime: 20 }), - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { QueueName: { - oldValue: { - }, - newValue: { - }, - isDifferent: true, - changeImpact: 'WILL_REPLACE', + Ref: 'hi', }, - ReceiveMessageWaitTimeSeconds: { - oldValue: 10, - newValue: 20, - isDifferent: true, - changeImpact: 'WILL_UPDATE', + ReceiveMessageWaitTimeSeconds: 10, + }, + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + Ref: 'bye', }, + ReceiveMessageWaitTimeSeconds: 10, }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, + }, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { + Ref: 'hi', }, + newValue: { + Ref: 'bye', + }, + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: '10', + newValue: '20', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, }, - isAddition: false, - isRemoval: false, - isImport: undefined, }, + isAddition: false, + isRemoval: false, + isImport: undefined, }, ); }); @@ -1044,663 +995,660 @@ describe('fullDiff tests that include changeset', () => { describe('method tests', () => { - test('InspectChangeSet correctly parses changeset', async () => { - // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + describe('TemplateAndChangeSetDiffMerger constructor', () => { - // THEN - expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); - expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ - resourceWasReplaced: true, - resourceType: 'AWS::SQS::Queue', - properties: { - QueueName: { - changeSetReplacementMode: 'Always', - beforeValue: 'newValuechangedddd', - afterValue: 'newValuesdflkja', + test('InspectChangeSet correctly parses changeset', async () => { + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + + // THEN + expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ + resourceWasReplaced: true, + resourceType: 'AWS::SQS::Queue', + propertyReplacementModes: { + QueueName: { + replacementMode: 'Always', + beforeValue: 'newValuechangedddd', + afterValue: 'newValuesdflkja', + }, }, - }, - }); - expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ - resourceWasReplaced: false, - resourceType: 'AWS::SSM::Parameter', - properties: { - Value: { - changeSetReplacementMode: 'Never', - beforeValue: 'changedddd', - afterValue: 'sdflkja', + }); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ + resourceWasReplaced: false, + resourceType: 'AWS::SSM::Parameter', + propertyReplacementModes: { + Value: { + replacementMode: 'Never', + beforeValue: 'changedddd', + afterValue: 'sdflkja', + }, }, - }, + }); }); - }); - test('TemplateAndChangeSetDiffMerger constructor can handle undefined changeset', async () => { + test('TemplateAndChangeSetDiffMerger constructor can handle undefined changeset', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); - // THEN - expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); - expect(templateAndChangeSetDiffMerger.changeSet).toEqual({}); - }); + // THEN + expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual({}); + }); - test('TemplateAndChangeSetDiffMerger constructor can handle undefined changes in changset.Changes', async () => { + test('TemplateAndChangeSetDiffMerger constructor can handle undefined changes in changset.Changes', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); - // THEN - expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); - expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithMissingChanges); - }); + // THEN + expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithMissingChanges); + }); - test('TemplateAndChangeSetDiffMerger constructor can handle partially defined changes in changset.Changes', async () => { + test('TemplateAndChangeSetDiffMerger constructor can handle partially defined changes in changset.Changes', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); - - // THEN - expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithPartiallyFilledChanges); - expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); - expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ - resourceWasReplaced: false, - resourceType: 'AWS::SSM::Parameter', - properties: {}, - }); - expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ - resourceWasReplaced: true, - resourceType: 'UNKNOWN_RESOURCE_TYPE', - properties: { - QueueName: { - changeSetReplacementMode: 'Always', - beforeValue: undefined, - afterValue: undefined, + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); + + // THEN + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithPartiallyFilledChanges); + expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ + resourceWasReplaced: false, + resourceType: 'AWS::SSM::Parameter', + properties: {}, + }); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ + resourceWasReplaced: true, + resourceType: 'UNKNOWN_RESOURCE_TYPE', + propertyReplacementModes: { + QueueName: { + replacementMode: 'Always', + beforeValue: undefined, + afterValue: undefined, + }, }, - }, + }); }); - }); - test('TemplateAndChangeSetDiffMerger constructor can handle undefined Details in changset.Changes', async () => { + test('TemplateAndChangeSetDiffMerger constructor can handle undefined Details in changset.Changes', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); - - // THEN - expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithUndefinedDetails); - expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(1); - expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ - resourceWasReplaced: true, - resourceType: 'UNKNOWN_RESOURCE_TYPE', - properties: {}, + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); + + // THEN + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithUndefinedDetails); + expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(1); + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ + resourceWasReplaced: true, + resourceType: 'UNKNOWN_RESOURCE_TYPE', + properties: {}, + }); }); + }); - test('determineChangeSetReplacementMode can evaluate missing Target', async () => { + describe('determineChangeSetReplacementMode ', () => { + test('can evaluate missing Target', async () => { // GIVEN - const propertyChangeWithMissingTarget = { - Target: undefined, - }; + const propertyChangeWithMissingTarget = { + Target: undefined, + }; - // WHEN - const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingTarget); + // WHEN + const replacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingTarget); - // THEN - expect(changeSetReplacementMode).toEqual('Conditionally'); - }); + // THEN + expect(replacementMode).toEqual('Conditionally'); + }); - test('determineChangeSetReplacementMode can evaluate missing RequiresRecreation', async () => { + test('can evaluate missing RequiresRecreation', async () => { // GIVEN - const propertyChangeWithMissingTargetDetail = { - Target: { RequiresRecreation: undefined }, - }; + const propertyChangeWithMissingTargetDetail = { + Target: { RequiresRecreation: undefined }, + }; - // WHEN - const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingTargetDetail); + // WHEN + const replacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingTargetDetail); - // THEN - expect(changeSetReplacementMode).toEqual('Conditionally'); - }); + // THEN + expect(replacementMode).toEqual('Conditionally'); + }); - test('determineChangeSetReplacementMode can evaluate Always and Static', async () => { + test('can evaluate Always and Static', async () => { // GIVEN - const propertyChangeWithAlwaysStatic: ResourceChangeDetail = { - Target: { RequiresRecreation: 'Always' }, - Evaluation: 'Static', - }; + const propertyChangeWithAlwaysStatic: ResourceChangeDetail = { + Target: { RequiresRecreation: 'Always' }, + Evaluation: 'Static', + }; - // WHEN - const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithAlwaysStatic); + // WHEN + const replacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithAlwaysStatic); - // THEN - expect(changeSetReplacementMode).toEqual('Always'); - }); + // THEN + expect(replacementMode).toEqual('Always'); + }); - test('determineChangeSetReplacementMode can evaluate always dynamic', async () => { + test('can evaluate always dynamic', async () => { // GIVEN - const propertyChangeWithAlwaysDynamic: ResourceChangeDetail = { - Target: { RequiresRecreation: 'Always' }, - Evaluation: 'Dynamic', - }; + const propertyChangeWithAlwaysDynamic: ResourceChangeDetail = { + Target: { RequiresRecreation: 'Always' }, + Evaluation: 'Dynamic', + }; - // WHEN - const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithAlwaysDynamic); + // WHEN + const replacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithAlwaysDynamic); - // THEN - expect(changeSetReplacementMode).toEqual('Conditionally'); - }); + // THEN + expect(replacementMode).toEqual('Conditionally'); + }); - test('determineChangeSetReplacementMode with missing Evaluation', async () => { + test('missing Evaluation', async () => { // GIVEN - const propertyChangeWithMissingEvaluation: ResourceChangeDetail = { - Target: { RequiresRecreation: 'Always' }, - Evaluation: undefined, - }; + const propertyChangeWithMissingEvaluation: ResourceChangeDetail = { + Target: { RequiresRecreation: 'Always' }, + Evaluation: undefined, + }; - // WHEN - const changeSetReplacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingEvaluation); + // WHEN + const replacementMode = TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChangeWithMissingEvaluation); + + // THEN + expect(replacementMode).toEqual('Always'); + }); - // THEN - expect(changeSetReplacementMode).toEqual('Always'); }); - test('addChangeSetResourcesToDiff can add resources that have template changes and changeset changes', async () => { - // GIVEN - const resources = new DifferenceCollection( - { - Queue: new ResourceDifference( - { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'first' } }, - { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::SQS::QUEUE', newType: 'AWS::SQS::QUEUE' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }, - ), - }, - ); + describe('overrideDiffResourcesWithChangeSetResources', () => { - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: { - DelaySeconds: { - changeSetReplacementMode: 'Conditionally', - afterValue: 10, - beforeValue: 2, + test('can add resources that have template changes and changeset changes', async () => { + // GIVEN + const resources = new DifferenceCollection( + { + Queue: new ResourceDifference( + { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'first' } }, + { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::SQS::QUEUE', newType: 'AWS::SQS::QUEUE' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, }, - }, - } as any, - }, - }); + ), + }, + ); + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + DelaySeconds: { + replacementMode: 'Conditionally', + afterValue: 10, + beforeValue: 2, + }, + }, + } as any, + }, + }); - //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); - // THEN - expect(resources.differenceCount).toBe(1); - expect(resources.changes.Queue.isUpdate).toBe(true); - expect(resources.changes.Queue).toEqual({ - oldValue: { - Type: 'AWS::SQS::QUEUE', - Properties: { - QueueName: 'first', + // THEN + expect(resources.differenceCount).toBe(1); + expect(resources.changes.Queue.isUpdate).toBe(true); + expect(resources.changes.Queue).toEqual({ + oldValue: { + Type: 'AWS::SQS::QUEUE', + Properties: { + QueueName: 'first', + }, }, - }, - newValue: { - Type: 'AWS::SQS::QUEUE', - Properties: { - QueueName: 'second', + newValue: { + Type: 'AWS::SQS::QUEUE', + Properties: { + QueueName: 'second', + }, }, - }, - resourceTypes: { - oldType: 'AWS::SQS::QUEUE', - newType: 'AWS::SQS::QUEUE', - }, - propertyDiffs: { - QueueName: { - oldValue: 'first', - newValue: 'second', - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - DelaySeconds: { - oldValue: 2, - newValue: 10, - isDifferent: true, - changeImpact: undefined, + resourceTypes: { + oldType: 'AWS::SQS::QUEUE', + newType: 'AWS::SQS::QUEUE', }, - }, - otherDiffs: { - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }); + propertyDiffs: { + QueueName: { + oldValue: 'first', + newValue: 'second', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + DelaySeconds: { + oldValue: 2, + newValue: 10, + isDifferent: true, + changeImpact: undefined, + }, + }, + otherDiffs: { + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); - }); + }); - test('addChangeSetResourcesToDiff can add resources from changeset', async () => { + test('can add resources from changeset', async () => { // GIVEN - const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); - //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); - // THEN - expect(resources.differenceCount).toBe(2); - expect(resources.changes.mySsmParameter.isUpdate).toBe(true); - expect(resources.changes.mySsmParameter).toEqual({ - oldValue: { - Type: 'AWS::SSM::Parameter', - Properties: { - Value: 'changedddd', + // THEN + expect(resources.differenceCount).toBe(2); + expect(resources.changes.mySsmParameter.isUpdate).toBe(true); + expect(resources.changes.mySsmParameter).toEqual({ + oldValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'changedddd', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, }, - }, - newValue: { - Type: 'AWS::SSM::Parameter', - Properties: { - Value: 'sdflkja', + newValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'sdflkja', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, }, - }, - resourceTypes: { - oldType: 'AWS::SSM::Parameter', - newType: 'AWS::SSM::Parameter', - }, - propertyDiffs: { - Value: { - oldValue: 'changedddd', - newValue: 'sdflkja', - isDifferent: true, - changeImpact: 'WILL_UPDATE', + resourceTypes: { + oldType: 'AWS::SSM::Parameter', + newType: 'AWS::SSM::Parameter', }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SSM::Parameter', - newValue: 'AWS::SSM::Parameter', - isDifferent: false, + propertyDiffs: { + Value: { + oldValue: 'changedddd', + newValue: 'sdflkja', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + Type: { + oldValue: 'String', + newValue: 'String', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + Name: { + oldValue: 'mySsmParameterFromStack', + newValue: 'mySsmParameterFromStack', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }); + otherDiffs: { + Type: { + oldValue: 'AWS::SSM::Parameter', + newValue: 'AWS::SSM::Parameter', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); - expect(resources.changes.Queue.isUpdate).toBe(true); - expect(resources.changes.Queue).toEqual({ - oldValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: 'newValuechangedddd', + expect(resources.changes.Queue.isUpdate).toBe(true); + expect(resources.changes.Queue).toEqual({ + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuechangedddd', + ReceiveMessageWaitTimeSeconds: '20', + }, }, - }, - newValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: 'newValuesdflkja', + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuesdflkja', + ReceiveMessageWaitTimeSeconds: '20', + }, }, - }, - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: 'newValuechangedddd', - newValue: 'newValuesdflkja', - isDifferent: true, - changeImpact: 'WILL_REPLACE', + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, + propertyDiffs: { + QueueName: { + oldValue: 'newValuechangedddd', + newValue: 'newValuesdflkja', + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: '20', + newValue: '20', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); }); - }); - test('addChangeSetResourcesToDiff can add resources from empty changeset', async () => { + test('can add resources from empty changeset', async () => { // GIVEN - const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); - //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); - // THEN - expect(resources.differenceCount).toBe(0); - expect(resources.changes).toEqual({}); + // THEN + expect(resources.differenceCount).toBe(0); + expect(resources.changes).toEqual({}); - }); + }); - test('addChangeSetResourcesToDiff can add resources from changeset that have undefined resourceType and Details', async () => { + test('can add resources from changeset that have undefined resourceType and Details', async () => { // GIVEN - const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); - //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); - - // THEN - expect(resources.differenceCount).toBe(0); - expect(resources.changes).toEqual({}); - - }); - - test('addChangeSetResourcesToDiff can add resources from changeset that have undefined properties', async () => { - // GIVEN - const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); - //WHEN - templateAndChangeSetDiffMerger.addChangeSetResourcesToDiffResources(resources); + // THEN + expect(resources.differenceCount).toBe(0); + expect(resources.changes).toEqual({}); - // THEN - expect(resources.differenceCount).toBe(1); - expect(resources.changes.Queue.isUpdate).toBe(true); - expect(resources.changes.Queue.oldValue).toEqual({ - Type: 'UNKNOWN_RESOURCE_TYPE', - Properties: { QueueName: undefined }, - }); - expect(resources.changes.Queue.oldValue).toEqual(resources.changes.Queue.newValue); - expect(resources.changes.Queue.propertyUpdates.QueueName).toEqual({ - oldValue: {}, - newValue: {}, - isDifferent: true, - changeImpact: undefined, // will be filled in by enhanceChangeImpact }); + }); - test('hydrateChangeImpactFromChangeset can handle blank change', async () => { - // GIVEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); - const queue = new ResourceDifference(undefined, undefined, { resourceType: {}, propertyDiffs: {}, otherDiffs: {} }); - const logicalId = 'Queue'; + describe('hydrateChangeImpactFromChangeset', () => { - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + test('can handle blank change', async () => { + // GIVEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: {} }); + const queue = new ResourceDifference(undefined, undefined, { resourceType: {}, propertyDiffs: {}, otherDiffs: {} }); + const logicalId = 'Queue'; - // THEN - expect(queue.isDifferent).toBe(false); - expect(queue.changeImpact).toBe('NO_CHANGE'); - }); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - test('hydrateChangeImpactFromChangeset ignores changes that are not in changeset', async () => { - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: {}, + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }, - ); - const logicalId = 'Queue'; - - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); - // THEN - expect(queue.isDifferent).toBe(false); - expect(queue.changeImpact).toBe('NO_CHANGE'); - }); + test('ignores changes that are not in changeset', async () => { + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: {}, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); + const logicalId = 'Queue'; - test('hydrateChangeImpactFromChangeset can handle undefined properties', async () => { - // GIVEN - const logicalId = 'Queue'; + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: {} as any, - }, + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }, - ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + test('can handle undefined properties', async () => { + // GIVEN + const logicalId = 'Queue'; - // THEN - expect(queue.isDifferent).toBe(false); - expect(queue.changeImpact).toBe('NO_CHANGE'); - }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: {} as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); - test('hydrateChangeImpactFromChangeset can handle empty properties', async () => { - // GIVEN - const logicalId = 'Queue'; + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: {}, - } as any, - }, + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }, - ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + test('can handle empty properties', async () => { + // GIVEN + const logicalId = 'Queue'; - // THEN - expect(queue.isDifferent).toBe(false); - expect(queue.changeImpact).toBe('NO_CHANGE'); - }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: {}, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); - test('hydrateChangeImpactFromChangeset can handle property without changeSetReplacementMode', async () => { - // GIVEN - const logicalId = 'Queue'; + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: { - QueueName: {} as any, - }, - } as any, - }, + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }, - ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + test('can handle property without replacementMode', async () => { + // GIVEN + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + QueueName: {} as any, + }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, + otherDiffs: {}, + }, + ); + + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - // THEN - expect(queue.isDifferent).toBe(false); - expect(queue.changeImpact).toBe('NO_CHANGE'); - }); + // THEN + expect(queue.isDifferent).toBe(false); + expect(queue.changeImpact).toBe('NO_CHANGE'); + }); - test('hydrateChangeImpactFromChangeset handles Never case', async () => { + test('handles Never case', async () => { // GIVEN - const logicalId = 'Queue'; - - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: { - QueueName: { - changeSetReplacementMode: 'Never', + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + QueueName: { + replacementMode: 'Never', + }, }, - }, - } as any, - }, - }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, - otherDiffs: {}, - }, - ); + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, + otherDiffs: {}, + }, + ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - // THEN - expect(queue.changeImpact).toBe('WILL_UPDATE'); - expect(queue.isDifferent).toBe(true); - }); + // THEN + expect(queue.changeImpact).toBe('WILL_UPDATE'); + expect(queue.isDifferent).toBe(true); + }); - test('hydrateChangeImpactFromChangeset handles Conditionally case', async () => { + test('handles Conditionally case', async () => { // GIVEN - const logicalId = 'Queue'; - - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: { - QueueName: { - changeSetReplacementMode: 'Conditionally', + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + QueueName: { + replacementMode: 'Conditionally', + }, }, - }, - } as any, - }, - }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, - otherDiffs: {}, - }, - ); + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, + otherDiffs: {}, + }, + ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - // THEN - expect(queue.changeImpact).toBe('MAY_REPLACE'); - expect(queue.isDifferent).toBe(true); - }); + // THEN + expect(queue.changeImpact).toBe('MAY_REPLACE'); + expect(queue.isDifferent).toBe(true); + }); - test('hydrateChangeImpactFromChangeset handles Always case', async () => { + test('handles Always case', async () => { // GIVEN - const logicalId = 'Queue'; - - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: { - QueueName: { - changeSetReplacementMode: 'Always', + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + QueueName: { + replacementMode: 'Always', + }, }, - }, - } as any, - }, - }); - const queue = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, - otherDiffs: {}, - }, - ); + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, + { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, + propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.NO_CHANGE }) }, + otherDiffs: {}, + }, + ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - // THEN - expect(queue.changeImpact).toBe('WILL_REPLACE'); - expect(queue.isDifferent).toBe(true); - }); + // THEN + expect(queue.changeImpact).toBe('WILL_REPLACE'); + expect(queue.isDifferent).toBe(true); + }); - test('hydrateChangeImpactFromChangeset returns if AWS::Serverless is resourcetype', async () => { + test('returns if AWS::Serverless is resourcetype', async () => { // GIVEN - const logicalId = 'Queue'; - - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - properties: { - QueueName: { - changeSetReplacementMode: 'Always', + const logicalId = 'Queue'; + + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + QueueName: { + replacementMode: 'Always', + }, }, + } as any, + }, + }); + const queue = new ResourceDifference( + { Type: 'AAWS::Serverless::IDK', Properties: { QueueName: 'first' } }, + { Type: 'AAWS::Serverless::IDK', Properties: { QueueName: 'second' } }, + { + resourceType: { oldType: 'AWS::Serverless::IDK', newType: 'AWS::Serverless::IDK' }, + propertyDiffs: { + QueueName: new PropertyDifference( 'first', 'second', + { changeImpact: ResourceImpact.WILL_ORPHAN }), // choose will_orphan to show that we're ignoring changeset }, - } as any, - }, - }); - const queue = new ResourceDifference( - { Type: 'AAWS::Serverless::IDK', Properties: { QueueName: 'first' } }, - { Type: 'AAWS::Serverless::IDK', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::Serverless::IDK', newType: 'AWS::Serverless::IDK' }, - propertyDiffs: { - QueueName: new PropertyDifference( 'first', 'second', - { changeImpact: ResourceImpact.WILL_ORPHAN }), // choose will_orphan to show that we're ignoring changeset + otherDiffs: {}, }, - otherDiffs: {}, - }, - ); + ); - //WHEN - templateAndChangeSetDiffMerger.hydrateChangeImpactFromChangeSet(logicalId, queue); + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, queue); - // THEN - expect(queue.changeImpact).toBe('WILL_ORPHAN'); - expect(queue.isDifferent).toBe(true); - }); - - test('it is an error to DifferenceCollection.set if a given logicalId is already in the collection', async () => { - // WHEN - const logicalId = 'NewAndUnseen1111'; - const resourceDiff = new ResourceDifference( - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'first' } }, - { Type: 'AWS::CDK::GREAT', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::CDK::GREAT', newType: 'AWS::CDK::GREAT' }, - propertyDiffs: { QueueName: new PropertyDifference('first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }); - const diffColl = new DifferenceCollection({ - [logicalId]: resourceDiff, + // THEN + expect(queue.changeImpact).toBe('WILL_ORPHAN'); + expect(queue.isDifferent).toBe(true); }); - // THEN - expect(() => { - diffColl.set(logicalId, resourceDiff); - }).toThrow(`LogicalId already exists in this DifferenceCollection. LogicalId: '${logicalId}'`); + test('Can handle old and new resourceType being UNKNOWN (diffResource might not like it)', async () => { + throw new Error('f'); + }); }); diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index 3a47cad1fd6ca..92f14232ce3e1 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -1,4 +1,4 @@ -import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; +import { Change, DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; export function template(resources: {[key: string]: any}) { return { Resources: resources }; @@ -86,81 +86,100 @@ export const ssmParam = { }, }; -export function sqsQueueWithAargs(args: { waitTime: number }) { +export function sqsQueueWithAargs(args: { waitTime: number; queueName?: string }) { return { Type: 'AWS::SQS::Queue', Properties: { QueueName: { - Ref: 'SsmParameterValuetestbugreportC9', + Ref: args.queueName ?? 'SsmParameterValuetestbugreportC9', }, ReceiveMessageWaitTimeSeconds: args.waitTime, }, }; } -export const changeSet: DescribeChangeSetOutput = { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - PolicyAction: 'ReplaceAndDelete', - Action: 'Modify', - LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', - ResourceType: 'AWS::SQS::Queue', - Replacement: 'True', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'QueueName', - RequiresRecreation: 'Always', - Path: '/Properties/QueueName', - BeforeValue: 'newValuechangedddd', - AfterValue: 'newValuesdflkja', - AttributeChangeType: 'Modify', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - BeforeContext: '{"Properties":{"QueueName":"newValuechangedddd","ReceiveMessageWaitTimeSeconds":"20"},"Metadata":{"aws:cdk:path":"cdkbugreport/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}', - AfterContext: '{"Properties":{"QueueName":"newValuesdflkja","ReceiveMessageWaitTimeSeconds":"20"},"Metadata":{"aws:cdk:path":"cdkbugreport/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}', +export const ssmParamFromChangeset: Change = { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'mySsmParameter', + PhysicalResourceId: 'mySsmParameterFromStack', + ResourceType: 'AWS::SSM::Parameter', + Replacement: 'False', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'Value', + RequiresRecreation: 'Never', + Path: '/Properties/Value', + BeforeValue: 'changedddd', + AfterValue: 'sdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', }, - }, - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'mySsmParameter', - PhysicalResourceId: 'mySsmParameterFromStack', - ResourceType: 'AWS::SSM::Parameter', - Replacement: 'False', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'Value', - RequiresRecreation: 'Never', - Path: '/Properties/Value', - BeforeValue: 'changedddd', - AfterValue: 'sdflkja', - AttributeChangeType: 'Modify', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', + ], + BeforeContext: '{"Properties":{"Value":"changedddd","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport/mySsmParameter/Resource"}}', + AfterContext: '{"Properties":{"Value":"sdflkja","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport/mySsmParameter/Resource"}}', + }, +}; + +export function queueFromChangeset(args: { beforeContextWaitTime?: string; afterContextWaitTime?: string } ): Change { + return { + Type: 'Resource', + ResourceChange: { + PolicyAction: 'ReplaceAndDelete', + Action: 'Modify', + LogicalResourceId: 'Queue', + PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'True', + Scope: [ + 'Properties', + ], + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'ReceiveMessageWaitTimeSeconds', + RequiresRecreation: 'Never', + Path: '/Properties/ReceiveMessageWaitTimeSeconds', + BeforeValue: args.beforeContextWaitTime ?? '20', + AfterValue: args.afterContextWaitTime ?? '20', + AttributeChangeType: 'Modify', }, - ], - BeforeContext: '{"Properties":{"Value":"changedddd","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport/mySsmParameter/Resource"}}', - AfterContext: '{"Properties":{"Value":"sdflkja","Type":"String","Name":"mySsmParameterFromStack"},"Metadata":{"aws:cdk:path":"cdkbugreport/mySsmParameter/Resource"}}', - }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + { + Target: { + Attribute: 'Properties', + Name: 'QueueName', + RequiresRecreation: 'Always', + Path: '/Properties/QueueName', + BeforeValue: 'newValuechangedddd', + AfterValue: 'newValuesdflkja', + AttributeChangeType: 'Modify', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + BeforeContext: `{"Properties":{"QueueName":"newValuechangedddd","ReceiveMessageWaitTimeSeconds":"${args.beforeContextWaitTime ?? '20'}"},"Metadata":{"aws:cdk:path":"cdkbugreport/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}`, + AfterContext: `{"Properties":{"QueueName":"newValuesdflkja","ReceiveMessageWaitTimeSeconds":"${args.afterContextWaitTime ?? '20'}"},"Metadata":{"aws:cdk:path":"cdkbugreport/Queue/Resource"},"UpdateReplacePolicy":"Delete","DeletionPolicy":"Delete"}`, }, + }; +} + +export const changeSet: DescribeChangeSetOutput = { + Changes: [ + queueFromChangeset({}), + ssmParamFromChangeset, ], ChangeSetName: 'newesteverr2223', ChangeSetId: 'arn:aws:cloudformation:us-east-1:012345678901:changeSet/newesteverr2223/3cb73e2d-d1c4-4331-9255-c978e496b6d1', @@ -191,55 +210,24 @@ export const changeSetWithMissingChanges = { ], }; +const copyOfssmChange = JSON.parse(JSON.stringify(ssmParamFromChangeset)); +copyOfssmChange.ResourceChange.ResourceType = undefined; +copyOfssmChange.ResourceChange.Details[0].Evaluation = undefined; +const copyOfQueueChange = JSON.parse(JSON.stringify(queueFromChangeset({}))); +copyOfQueueChange.ResourceChange.Details[0].Target = undefined; +copyOfQueueChange.ResourceChange.ResourceType = undefined; +const afterContext = JSON.parse(copyOfQueueChange.ResourceChange?.AfterContext); +afterContext.Properties.QueueName = undefined; +copyOfQueueChange.ResourceChange.AfterContext = afterContext; +const beforeContext = JSON.parse(copyOfQueueChange.ResourceChange?.BeforeContext); +beforeContext.Properties.Random = 'nice'; +beforeContext.Properties.QueueName = undefined; +copyOfQueueChange.ResourceChange.BeforeContext = beforeContext; + export const changeSetWithPartiallyFilledChanges: DescribeChangeSetOutput = { Changes: [ - { - Type: 'Resource', - ResourceChange: { - PolicyAction: 'ReplaceAndDelete', - Action: 'Modify', - LogicalResourceId: 'Queue', - PhysicalResourceId: 'https://sqs.us-east-1.amazonaws.com/012345678901/newValuechangedddd', - ResourceType: undefined, - Replacement: 'True', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'QueueName', - RequiresRecreation: 'Always', - Path: '/Properties/QueueName', - AttributeChangeType: 'Modify', - }, - Evaluation: undefined, - ChangeSource: 'DirectModification', - }, - ], - }, - }, - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'mySsmParameter', - PhysicalResourceId: 'mySsmParameterFromStack', - ResourceType: 'AWS::SSM::Parameter', - Replacement: 'False', - Scope: [ - 'Properties', - ], - Details: [ - { - Target: undefined, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, + ssmParamFromChangeset, + copyOfQueueChange, ], }; diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index 6941803d59ac6..b153ae3977ce4 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -198,14 +198,28 @@ async function describeChangeSet( { fetchAll }: { fetchAll: boolean }, ): Promise { const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise(); - const changesWithPropertyValues = (await cfn.describeChangeSet({ - StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true, - }).promise()) - .Changes; - // As of now, describeChangeSet doesn't include the StatusReason if you specify IncludePropertyValues. - // So, make a call to get the Changes with property values and a call to get the other outputs. - response.Changes = changesWithPropertyValues; + try { + // * As of now, describeChangeSet with IncludePropertyValues doesn't include the StatusReason for a ChangeSet with no changes. There is a fix that's in progress from CloudFormation. + // * As of now, the IncludePropertyValues feature is not available in all AWS partitions. + // * TODO: Once the above are fixed, this try catch block can be removed and we can make a single DescribeChangeSet request. + const changesWithPropertyValues = (await cfn.describeChangeSet({ + StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true, + } as any).promise()) + .Changes; + + const changeContextIncludedInResponse = changesWithPropertyValues?.find((change) => + (change?.ResourceChange?.AfterContext !== undefined) || (change?.ResourceChange?.BeforeContext !== undefined), + ); + if (changeContextIncludedInResponse) { + response.Changes = changesWithPropertyValues; + } else { + // We don't want to assume that failure to use the new IncludePropertyValues field will result in an exception being thrown. + debug('describeChangeSet with IncludePropertyValues has no BeforeContext or AfterContext. Diff will not include property values from ChangeSet.'); + } + } catch (e: any) { + debug('Failed to describeChangeSet with IncludePropertyValues. Diff will not include property values from ChangeSet. Error Message: %s', e?.message); + } // If fetchAll is true, traverse all pages from the change set description. while (fetchAll && response.NextToken != null) { @@ -328,7 +342,6 @@ export async function createDiffChangeSet(options: PrepareChangeSetOptions): Pro // This causes CreateChangeSet to fail with `Template Error: Fn::Equals cannot be partially collapsed`. for (const resource of Object.values((options.stack.template.Resources ?? {}))) { if ((resource as any).Type === 'AWS::CloudFormation::Stack') { - // eslint-disable-next-line no-console debug('This stack contains one or more nested stacks, falling back to template-only diff...'); return undefined; From 6fcfafd95a832eaeef124bb26185a208e278f6a3 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Wed, 22 May 2024 16:09:43 -0400 Subject: [PATCH 30/36] stuff --- .../template-and-changeset-diff-merger.ts | 4 +- .../aws-cdk/lib/api/util/cloudformation.ts | 69 ++++++++++++------- packages/aws-cdk/test/diff.test.ts | 31 +++++++++ 3 files changed, 78 insertions(+), 26 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index c8d4537dcb77e..c4f0a48d7b45e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -100,11 +100,11 @@ export class TemplateAndChangeSetDiffMerger { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const oldResource: types.Resource = { Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: changeSetResource.beforeContext?.Properties, + Properties: changeSetResource.beforeContext?.Properties ?? { ChangeSetPlaceHolder: 'BEFORE_DETAIL_NOT_VIEWABLE' }, }; const newResource: types.Resource = { Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: changeSetResource.afterContext?.Properties, + Properties: changeSetResource.afterContext?.Properties ?? { ChangeSetPlaceHolder: 'AFTER_DETAIL_NOT_VIEWABLE' }, }; const resourceDiffFromChangeset = diffResource(oldResource, newResource); diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index b153ae3977ce4..ad723e16e9d35 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -191,7 +191,7 @@ export class CloudFormationStack { * * @returns CloudFormation information about the ChangeSet */ -async function describeChangeSet( +export async function describeChangeSet( cfn: CloudFormation, stackName: string, changeSetName: string, @@ -199,36 +199,23 @@ async function describeChangeSet( ): Promise { const response = await cfn.describeChangeSet({ StackName: stackName, ChangeSetName: changeSetName }).promise(); - try { - // * As of now, describeChangeSet with IncludePropertyValues doesn't include the StatusReason for a ChangeSet with no changes. There is a fix that's in progress from CloudFormation. - // * As of now, the IncludePropertyValues feature is not available in all AWS partitions. - // * TODO: Once the above are fixed, this try catch block can be removed and we can make a single DescribeChangeSet request. - const changesWithPropertyValues = (await cfn.describeChangeSet({ - StackName: stackName, ChangeSetName: changeSetName, IncludePropertyValues: true, - } as any).promise()) - .Changes; - - const changeContextIncludedInResponse = changesWithPropertyValues?.find((change) => - (change?.ResourceChange?.AfterContext !== undefined) || (change?.ResourceChange?.BeforeContext !== undefined), - ); - if (changeContextIncludedInResponse) { - response.Changes = changesWithPropertyValues; - } else { - // We don't want to assume that failure to use the new IncludePropertyValues field will result in an exception being thrown. - debug('describeChangeSet with IncludePropertyValues has no BeforeContext or AfterContext. Diff will not include property values from ChangeSet.'); - } - } catch (e: any) { - debug('Failed to describeChangeSet with IncludePropertyValues. Diff will not include property values from ChangeSet. Error Message: %s', e?.message); + const changeSetChangesWithContext = await maybeGetChangeSetChangeContext(cfn, stackName, changeSetName); + if (changeSetChangesWithContext) { + response.Changes = changeSetChangesWithContext.Changes; } // If fetchAll is true, traverse all pages from the change set description. while (fetchAll && response.NextToken != null) { - const nextPage = await cfn.describeChangeSet({ + const input: any = { StackName: stackName, ChangeSetName: response.ChangeSetId ?? changeSetName, NextToken: response.NextToken, - IncludePropertyValues: true, - }).promise(); + }; + if (changeSetChangesWithContext) { + input.IncludePropertyValues = true; + } + + const nextPage = await cfn.describeChangeSet(input).promise(); // Consolidate the changes if (nextPage.Changes != null) { @@ -244,6 +231,40 @@ async function describeChangeSet( return response; } +/** + * * As of now, describeChangeSet with IncludePropertyValues doesn't include the StatusReason for a ChangeSet with no changes. There is a fix that's in progress from CloudFormation. + * * As of now, the IncludePropertyValues feature is not available in all AWS partitions. + * * TODO: Once the above are fixed, we can remove this function and make all describeChangeSet requests with IncludePropertValues set to true. + */ +export async function maybeGetChangeSetChangeContext( + cfn: CloudFormation, + stackName: string, + changeSetName: string, +): Promise { + let changeContextIncludedInResponse = undefined; + try { + const changesWithPropertyValues = await cfn.describeChangeSet({ + StackName: stackName, + ChangeSetName: changeSetName, + IncludePropertyValues: true, + }).promise(); + + changeContextIncludedInResponse = changesWithPropertyValues?.Changes?.find((change) => + (change?.ResourceChange?.AfterContext !== undefined) || (change?.ResourceChange?.BeforeContext !== undefined), + ); + if (changeContextIncludedInResponse) { + return changesWithPropertyValues; + } + + // We don't want to assume that failure to use the new IncludePropertyValues field will result in an exception being thrown. + debug('describeChangeSet with IncludePropertyValues has no BeforeContext or AfterContext. Diff will not include property values from ChangeSet.'); + } catch (e: any) { + debug('Failed to describeChangeSet with IncludePropertyValues. Diff will not include property values from ChangeSet. Error Message: %s', e?.message); + } + + return undefined; +} + /** * Waits for a function to return non-+undefined+ before returning. * diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index 0155f74dd192d..7a825fd2a2f04 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -9,6 +9,7 @@ import { CdkToolkit } from '../lib/cdk-toolkit'; import * as cfn from '../lib/api/util/cloudformation'; import { NestedStackTemplates } from '../lib/api/nested-stack-helpers'; import * as fs from 'fs'; +// import * as AWS from 'aws-sdk'; let cloudExecutable: MockCloudExecutable; let cloudFormation: jest.Mocked; @@ -205,6 +206,8 @@ Resources // THEN const plainTextOutput = buffer.data.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); expect(cfn.createDiffChangeSet).toHaveBeenCalled(); + // eslint-disable-next-line no-console + console.log(plainTextOutput); expect(plainTextOutput).toContain(`Stack A Parameters and rules created during migration do not affect resource configuration. Resources @@ -545,6 +548,34 @@ describe('stack exists checks', () => { }); }); +// describe('DescribeChangeSet', () => { + +// test('DescribeChangeSet doesnt include IncludePropertyValue in request if no change context', async () => { +// // GIVEN +// const changeSetSpy = jest.spyOn(cfn, 'maybeGetChangeSetChangeContext'); +// changeSetSpy.mockResolvedValue(undefined); + +// jest.mock('aws-sdk', () => { +// const mockDescribeChangeSet = jest.fn().mockReturnValue({ +// promise: jest.fn().mockRejectedValueOnce({ Changes: [], NextToken: 'hi' }), +// }); + +// return { +// CloudFormation: jest.fn(() => ({ +// describeChangeSet: mockDescribeChangeSet, +// })), +// }; +// }); + +// // WHEN +// const cfnClient = new AWS.CloudFormation(); +// await cfn.describeChangeSet(cfnClient, 'stack', 'changeSet', { fetchAll: true }); + +// // THEN +// }); + +// }); + describe('nested stacks', () => { beforeEach(() => { cloudExecutable = new MockCloudExecutable({ From 84ce20b04c30d45755b23136eefcf7c67ce62779 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Thu, 23 May 2024 07:36:11 -0400 Subject: [PATCH 31/36] handle regions where describeChangeSet.IncludePropertyValues is not an option --- .../template-and-changeset-diff-merger.ts | 10 +- .../cloudformation-diff/lib/diff/types.ts | 8 +- .../aws-cdk/lib/api/util/cloudformation.ts | 1 - packages/aws-cdk/test/diff.test.ts | 169 +++++++++++++++--- 4 files changed, 159 insertions(+), 29 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index c4f0a48d7b45e..34501cd920c41 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -58,11 +58,15 @@ export class TemplateAndChangeSetDiffMerger { } const propertyReplacementModes: types.PropertyReplacementModeMap = {}; + const beforeContextBackup: types.PropertyNameMap = { Properties: {} }; // TODO: delete once IncludePropertyValues for DescribeChangeSets is available in all regions + const afterContextBackup: types.PropertyNameMap = { Properties: {} }; // TODO: delete once IncludePropertyValues for DescribeChangeSets is available in all regions for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { propertyReplacementModes[propertyChange.Target.Name] = { replacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), }; + beforeContextBackup.Properties[propertyChange.Target.Name] = 'value_before_change_is_not_viewable'; + afterContextBackup.Properties[propertyChange.Target.Name] = 'value_after_change_is_not_viewable'; } } @@ -71,7 +75,9 @@ export class TemplateAndChangeSetDiffMerger { resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChangeSet doesn't promise to have the ResourceType... propertyReplacementModes: propertyReplacementModes, beforeContext: _maybeJsonParse(resourceChange.ResourceChange.BeforeContext), + beforeContextBackup: beforeContextBackup, afterContext: _maybeJsonParse(resourceChange.ResourceChange.AfterContext), + afterContextBackup: afterContextBackup, }; } @@ -100,11 +106,11 @@ export class TemplateAndChangeSetDiffMerger { for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { const oldResource: types.Resource = { Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: changeSetResource.beforeContext?.Properties ?? { ChangeSetPlaceHolder: 'BEFORE_DETAIL_NOT_VIEWABLE' }, + Properties: changeSetResource.beforeContext?.Properties ?? changeSetResource.beforeContextBackup.Properties, }; const newResource: types.Resource = { Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: changeSetResource.afterContext?.Properties ?? { ChangeSetPlaceHolder: 'AFTER_DETAIL_NOT_VIEWABLE' }, + Properties: changeSetResource.afterContext?.Properties ?? changeSetResource.afterContextBackup.Properties, }; const resourceDiffFromChangeset = diffResource(oldResource, newResource); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index ee239e40aed50..c954cec062c2e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -11,17 +11,23 @@ export type ChangeSetResources = { [logicalId: string]: ChangeSetResource }; /** * @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. + * @param beforeContextBackup this is a map of property names from the resource in the changeset. This should be used if the beforeContext is undefined. + * - TODO: This field can be removed once IncludePropertyValues is available in all regions. * * @param afterContext same as beforeContext but for after the change is made. + * @param beforeContextBackup this is a map of property names from the resource in the changeset. This should be used if the afterContext is undefined. + * - TODO: This field can be removed once IncludePropertyValues is available in all regions. */ export interface ChangeSetResource { resourceWasReplaced: boolean; resourceType: string | undefined; propertyReplacementModes: PropertyReplacementModeMap | undefined; beforeContext: any | undefined; + beforeContextBackup: PropertyNameMap; afterContext: any | undefined; + afterContextBackup: PropertyNameMap; } - +export type PropertyNameMap = { Properties: {[propertyName: string]: string} }; export type PropertyReplacementModeMap = { [propertyName: string]: { replacementMode: ReplacementModes | undefined; diff --git a/packages/aws-cdk/lib/api/util/cloudformation.ts b/packages/aws-cdk/lib/api/util/cloudformation.ts index ad723e16e9d35..d57dcef732ad7 100644 --- a/packages/aws-cdk/lib/api/util/cloudformation.ts +++ b/packages/aws-cdk/lib/api/util/cloudformation.ts @@ -298,7 +298,6 @@ async function waitFor(valueProvider: () => Promise, ti * * @returns the CloudFormation description of the ChangeSet */ -// eslint-disable-next-line max-len export async function waitForChangeSet( cfn: CloudFormation, stackName: string, diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index 7a825fd2a2f04..fb9695e37857c 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -9,7 +9,8 @@ import { CdkToolkit } from '../lib/cdk-toolkit'; import * as cfn from '../lib/api/util/cloudformation'; import { NestedStackTemplates } from '../lib/api/nested-stack-helpers'; import * as fs from 'fs'; -// import * as AWS from 'aws-sdk'; +import { MockSdkProvider } from './util/mock-sdk'; +import { CloudFormation } from 'aws-sdk'; let cloudExecutable: MockCloudExecutable; let cloudFormation: jest.Mocked; @@ -100,18 +101,21 @@ describe('imports', () => { ResourceChange: { Action: 'Import', LogicalResourceId: 'Queue', + Details: [{ Target: { Attribute: 'Properties', Name: 'RandomPropertyField', RequiresRecreation: 'Never' } }], }, }, { ResourceChange: { Action: 'Import', LogicalResourceId: 'Bucket', + Details: [{ Target: { Attribute: 'Properties', Name: 'RandomPropertyField', RequiresRecreation: 'Never' } }], }, }, { ResourceChange: { Action: 'Import', LogicalResourceId: 'Queue2', + Details: [{ Target: { Attribute: 'Properties', Name: 'RandomPropertyField', RequiresRecreation: 'Never' } }], }, }, ], @@ -211,9 +215,18 @@ Resources expect(plainTextOutput).toContain(`Stack A Parameters and rules created during migration do not affect resource configuration. Resources -[←] AWS::SQS::Queue Queue import -[←] AWS::SQS::Queue Queue2 import -[←] AWS::S3::Bucket Bucket import +[←] UNKNOWN_RESOURCE_TYPE Queue import + └─ [~] RandomPropertyField + ├─ [-] value_before_change_is_not_viewable + └─ [+] value_after_change_is_not_viewable +[←] UNKNOWN_RESOURCE_TYPE Queue2 import + └─ [~] RandomPropertyField + ├─ [-] value_before_change_is_not_viewable + └─ [+] value_after_change_is_not_viewable +[←] UNKNOWN_RESOURCE_TYPE Bucket import + └─ [~] RandomPropertyField + ├─ [-] value_before_change_is_not_viewable + └─ [+] value_after_change_is_not_viewable `); expect(buffer.data.trim()).toContain('✨ Number of stacks with differences: 1'); @@ -548,33 +561,139 @@ describe('stack exists checks', () => { }); }); -// describe('DescribeChangeSet', () => { +describe('DescribeChangeSet', () => { + let cfnClientMock: CloudFormation; + let describeChangeSetsMock: jest.Mock; + + beforeEach(async () => { + const sdkProvider = new MockSdkProvider(); + describeChangeSetsMock = jest.fn(); + const cfnMocks = { describeChangeSet: describeChangeSetsMock }; + sdkProvider.stubCloudFormation(cfnMocks as any); + cfnClientMock = (await sdkProvider.forEnvironment()).sdk.cloudFormation(); + }); + + afterEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + // TODO -- remove this test once IncludePropertyValues is supported in all regions. The purpose of this test is to ensure that we only specify IncludePropertyValues on the appropriate call. + // If we were to use IncludePropertyValues on the wrong call to DescribeChangeSet, then we could break `cdk diff` in regions where IncludePropertyValues doesn't work. + test('DescribeChangeSet doesnt include IncludePropertyValue in request if first call with IncludePropertyValue throws error', async () => { + // GIVEN + const stackName = 'stack'; + const changeSetName = 'changeSet'; + const nextToken = 'next'; + const changesWithoutBeforeAndAfterContext = [{ + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'Queue', + Details: [{ Target: { Attribute: 'Properties', Name: 'RandomPropertyField', RequiresRecreation: 'Never' } }], + }, + }]; + + describeChangeSetsMock + .mockImplementationOnce(() => ({ Changes: changesWithoutBeforeAndAfterContext, NextToken: nextToken })) + .mockImplementationOnce(() => { throw new Error('IncludePropertyValues not supported in this region'); }) + .mockImplementationOnce(() => ({ Changes: [], NextToken: undefined })); + + // WHEN + await cfn.describeChangeSet(cfnClientMock, stackName, changeSetName, { fetchAll: true }); + const commonExpectedArgs = { StackName: stackName, ChangeSetName: changeSetName }; + + // THEN + expect(describeChangeSetsMock).toHaveBeenCalledTimes(3); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(1, commonExpectedArgs); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(2, { + ...commonExpectedArgs, + IncludePropertyValues: true, + }); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(3, { + ...commonExpectedArgs, + NextToken: nextToken, + }); + }); + + // TODO -- remove this test once IncludePropertyValues is supported in all regions. The purpose of this test is to ensure that we only specify IncludePropertyValues on the appropriate call. + // If we were to use IncludePropertyValues on the wrong call to DescribeChangeSet, then we could break `cdk diff` in regions where IncludePropertyValues doesn't work. + test('DescribeChangeSet doesnt include IncludePropertyValue in request if no change context', async () => { + // GIVEN + const stackName = 'stack'; + const changeSetName = 'changeSet'; + const nextToken = 'next'; + const changesWithoutBeforeAndAfterContext = [{ + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'Queue', + Details: [{ Target: { Attribute: 'Properties', Name: 'RandomPropertyField', RequiresRecreation: 'Never' } }], + }, + }]; -// test('DescribeChangeSet doesnt include IncludePropertyValue in request if no change context', async () => { -// // GIVEN -// const changeSetSpy = jest.spyOn(cfn, 'maybeGetChangeSetChangeContext'); -// changeSetSpy.mockResolvedValue(undefined); + describeChangeSetsMock + .mockImplementationOnce(() => ({ Changes: changesWithoutBeforeAndAfterContext, NextToken: nextToken })) + .mockImplementationOnce(() => ({ Changes: changesWithoutBeforeAndAfterContext, NextToken: undefined })) + .mockImplementationOnce(() => ({ Changes: [], NextToken: undefined })); -// jest.mock('aws-sdk', () => { -// const mockDescribeChangeSet = jest.fn().mockReturnValue({ -// promise: jest.fn().mockRejectedValueOnce({ Changes: [], NextToken: 'hi' }), -// }); + // WHEN + await cfn.describeChangeSet(cfnClientMock, stackName, changeSetName, { fetchAll: true }); + const commonExpectedArgs = { StackName: stackName, ChangeSetName: changeSetName }; -// return { -// CloudFormation: jest.fn(() => ({ -// describeChangeSet: mockDescribeChangeSet, -// })), -// }; -// }); + // THEN + expect(describeChangeSetsMock).toHaveBeenCalledTimes(3); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(1, commonExpectedArgs); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(2, { + ...commonExpectedArgs, + IncludePropertyValues: true, + }); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(3, { + ...commonExpectedArgs, + NextToken: nextToken, + }); + }); -// // WHEN -// const cfnClient = new AWS.CloudFormation(); -// await cfn.describeChangeSet(cfnClient, 'stack', 'changeSet', { fetchAll: true }); + // TODO -- change this test once IncludePropertyValues is supported in all regions. The purpose of this test is to ensure that we only specify IncludePropertyValues on the appropriate call. + // If we were to use IncludePropertyValues on the wrong call to DescribeChangeSet, then we could break `cdk diff` in regions where IncludePropertyValues doesn't work. + test('DescribeChangeSet does include IncludePropertyValue in request if change context exists', async () => { + // GIVEN + const stackName = 'stack'; + const changeSetName = 'changeSet'; + const nextToken = 'next'; + const changesWithBeforeContext = [{ + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'Queue', + BeforeContext: '{"hi": "hello"}', + Details: [{ Target: { Attribute: 'Properties', Name: 'RandomPropertyField', RequiresRecreation: 'Never' } }], + }, + }]; + + describeChangeSetsMock + .mockImplementationOnce(() => ({ Changes: [], NextToken: nextToken })) + .mockImplementationOnce(() => ({ Changes: changesWithBeforeContext, NextToken: undefined })) + .mockImplementationOnce(() => ({ Changes: [], NextToken: undefined })); -// // THEN -// }); + // WHEN + const response = await cfn.describeChangeSet(cfnClientMock, stackName, changeSetName, { fetchAll: true }); + expect(response.Changes).toEqual(changesWithBeforeContext); + const commonExpectedArgs = { StackName: stackName, ChangeSetName: changeSetName }; -// }); + // THEN + expect(describeChangeSetsMock).toHaveBeenCalledTimes(3); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(1, commonExpectedArgs); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(2, { + ...commonExpectedArgs, + IncludePropertyValues: true, + }); + expect(describeChangeSetsMock).toHaveBeenNthCalledWith(3, { + ...commonExpectedArgs, + NextToken: nextToken, + IncludePropertyValues: true, + }); + }); + +}); describe('nested stacks', () => { beforeEach(() => { From e65abf7877bbc113f30f7a47035b6edf2b0afcb6 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Thu, 23 May 2024 07:46:02 -0400 Subject: [PATCH 32/36] clean up comments --- .../cli-integ/tests/cli-integ-tests/cli.integtest.ts | 2 +- packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts | 2 +- packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts | 3 --- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts index dbd762442f63a..96458480cc586 100644 --- a/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts +++ b/packages/@aws-cdk-testing/cli-integ/tests/cli-integ-tests/cli.integtest.ts @@ -958,7 +958,7 @@ integTest('cdk diff picks up security changes that are only in changeset', withD await fixture.cdkDeploy('iam-role-defined-by-ssm-param'); // WHEN - // We want to change the ssm value. Then the CFN changeset will detect that the queue will be changed upon deploy. + // We want to change the ssm value. Then the CFN changeset will detect that the iam-role will be changed upon deploy. const newRoleName = randomString(); await fixture.aws.ssm('putParameter', { Name: 'for-iam-role-defined-by-ssm-param', diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 76b7509b109e8..385503ad48e81 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -54,7 +54,7 @@ export function fullDiff( normalize(currentTemplate); normalize(newTemplate); - let theDiff = diffTemplate(currentTemplate, newTemplate); // I could remove this step and then run the integ tests and see what happens, assuming those tests use changeset + let theDiff = diffTemplate(currentTemplate, newTemplate); if (changeSet) { // These methods mutate the state of theDiff, using the changeSet. const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet }); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index c954cec062c2e..217e5186a5bcd 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -395,9 +395,6 @@ export class DifferenceCollection> { delete this.diffs[logicalId]; } - /** - * Throw an error to prevent the caller from overwriting a change. - */ public set(logicalId: string, diff: T): void { this.diffs[logicalId] = diff; } From f2219714d36693fc97e29f33dc4db3c1b2e3f22c Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Thu, 23 May 2024 17:46:34 -0400 Subject: [PATCH 33/36] removed context backups --- .../cloudformation-diff/lib/diff-template.ts | 1 + .../template-and-changeset-diff-merger.ts | 96 +- .../cloudformation-diff/lib/diff/types.ts | 20 +- ...template-and-changeset-diff-merger.test.ts | 2367 +++++++++-------- .../@aws-cdk/cloudformation-diff/test/util.ts | 4 +- 5 files changed, 1361 insertions(+), 1127 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index 385503ad48e81..d8982de5e848a 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -105,6 +105,7 @@ export function diffTemplate( .filter(r => isReplacement(r!.changeImpact)) .forEachDifference((logicalId, downstreamReplacement) => { const resource = theDiff.resources.get(logicalId); + if (!resource) { throw new Error(`No object with logical ID '${logicalId}'`); } if (resource.changeImpact !== downstreamReplacement.changeImpact) { propagatePropertyReplacement(downstreamReplacement, resource); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index 34501cd920c41..dbcb5a721fbd5 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -58,15 +58,11 @@ export class TemplateAndChangeSetDiffMerger { } const propertyReplacementModes: types.PropertyReplacementModeMap = {}; - const beforeContextBackup: types.PropertyNameMap = { Properties: {} }; // TODO: delete once IncludePropertyValues for DescribeChangeSets is available in all regions - const afterContextBackup: types.PropertyNameMap = { Properties: {} }; // TODO: delete once IncludePropertyValues for DescribeChangeSets is available in all regions for (const propertyChange of resourceChange.ResourceChange.Details ?? []) { if (propertyChange.Target?.Attribute === 'Properties' && propertyChange.Target.Name) { propertyReplacementModes[propertyChange.Target.Name] = { replacementMode: TemplateAndChangeSetDiffMerger.determineChangeSetReplacementMode(propertyChange), }; - beforeContextBackup.Properties[propertyChange.Target.Name] = 'value_before_change_is_not_viewable'; - afterContextBackup.Properties[propertyChange.Target.Name] = 'value_after_change_is_not_viewable'; } } @@ -75,9 +71,7 @@ export class TemplateAndChangeSetDiffMerger { resourceType: resourceChange.ResourceChange.ResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, // DescribeChangeSet doesn't promise to have the ResourceType... propertyReplacementModes: propertyReplacementModes, beforeContext: _maybeJsonParse(resourceChange.ResourceChange.BeforeContext), - beforeContextBackup: beforeContextBackup, afterContext: _maybeJsonParse(resourceChange.ResourceChange.AfterContext), - afterContextBackup: afterContextBackup, }; } @@ -103,18 +97,88 @@ export class TemplateAndChangeSetDiffMerger { * This is a more accurate way of computing the resource differences, since now cdk diff is reporting directly what the ChangeSet will apply. */ public overrideDiffResourcesWithChangeSetResources(resourceDiffs: types.DifferenceCollection) { - for (const [logicalId, changeSetResource] of Object.entries(this.changeSetResources)) { - const oldResource: types.Resource = { - Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: changeSetResource.beforeContext?.Properties ?? changeSetResource.beforeContextBackup.Properties, - }; - const newResource: types.Resource = { - Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: changeSetResource.afterContext?.Properties ?? changeSetResource.afterContextBackup.Properties, - }; + for (const [logicalIdFromChangeSet, changeSetResource] of Object.entries(this.changeSetResources)) { + let oldResource: types.Resource; + const changeSetIncludedBeforeContext = changeSetResource.beforeContext !== undefined; + if (changeSetIncludedBeforeContext) { + oldResource = { + Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + ...changeSetResource.beforeContext, // This is what CfnTemplate.Resources[LogicalId] is before the change, with ssm params resolved. + }; + } else { + // TODO -- once IncludePropertyValues is supported in all regions for changesets, delete this else branch. Only above case will occur. + oldResource = this.convertContextlessChangeSetResourceToResource( + changeSetResource.resourceType, + resourceDiffs.get(logicalIdFromChangeSet)?.oldValue, + { + propertiesThatChanged: Object.keys(changeSetResource.propertyReplacementModes || {}), + oldOrNew: 'OLD', + }, + ); + } + + let newResource: types.Resource; + const changeSetIncludedAfterContext = changeSetResource.afterContext !== undefined; + if (changeSetIncludedAfterContext) { + newResource = { + Type: changeSetResource.resourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + ...changeSetResource.afterContext, // This is what CfnTemplate.Resources[LogicalId] is after the change, with ssm params resolved. + }; + } else { + // TODO -- once IncludePropertyValues is supported in all regions for changesets, delete this else branch. Only above case will occur. + newResource = this.convertContextlessChangeSetResourceToResource( + changeSetResource.resourceType, + resourceDiffs.get(logicalIdFromChangeSet)?.newValue, + { + propertiesThatChanged: Object.keys(changeSetResource.propertyReplacementModes || {}), + oldOrNew: 'NEW', + }, + ); + } const resourceDiffFromChangeset = diffResource(oldResource, newResource); - resourceDiffs.set(logicalId, resourceDiffFromChangeset); + resourceDiffs.set(logicalIdFromChangeSet, resourceDiffFromChangeset); + } + } + + /** + * TODO: Once IncludePropertyValues is supported in all regions, this function can be deleted + */ + public convertContextlessChangeSetResourceToResource( + changeSetResourceResourceType: string | undefined, + oldOrNewValueFromTemplateDiff: types.Resource | undefined, + args: { + propertiesThatChanged: string[]; + oldOrNew: 'OLD' | 'NEW'; + }, + ): types.Resource { + const backupMessage = args.oldOrNew === 'NEW' ? 'value_after_change_is_not_viewable' : 'value_before_change_is_not_viewable'; + const resourceExistsInTemplateDiff = oldOrNewValueFromTemplateDiff !== undefined; + if (resourceExistsInTemplateDiff) { + // if resourceExistsInTemplateDiff, then we don't want to erase the details of property changes that are in the template diff -- but we want + // to make sure all changes from the ChangeSet are mentioned. At this point, since BeforeContext and AfterContext were not available from the + // ChangeSet, we can't get the before and after values of the properties from the changeset. + // So, the best we can do for the properties that aren't in the template diff is mention that they'll be changing. + + if (oldOrNewValueFromTemplateDiff?.Properties === undefined) { + oldOrNewValueFromTemplateDiff.Properties = {}; + } + + // write properties from changeset that are missing from the template diff + for (const propertyName of args.propertiesThatChanged) { + if (!(propertyName in oldOrNewValueFromTemplateDiff.Properties)) { + oldOrNewValueFromTemplateDiff.Properties[propertyName] = backupMessage; + } + } + return oldOrNewValueFromTemplateDiff; + } else { + // The resource didn't change in the templateDiff but is mentioned in the changeset. E.g., perhaps because an ssm parameter, that defined a property, changed value. + const propsWithBackUpMessage: { [propertyName: string]: string } = {}; + for (const propName of args.propertiesThatChanged) { propsWithBackUpMessage[propName] = backupMessage; } + return { + Type: changeSetResourceResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + Properties: propsWithBackUpMessage, + }; } } diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts index 217e5186a5bcd..bae076828e92e 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/types.ts @@ -10,24 +10,20 @@ export type ChangeSetResources = { [logicalId: string]: ChangeSetResource }; /** * @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. - * @param beforeContextBackup this is a map of property names from the resource in the changeset. This should be used if the beforeContext is undefined. - * - TODO: This field can be removed once IncludePropertyValues is available in all regions. + * 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. - * @param beforeContextBackup this is a map of property names from the resource in the changeset. This should be used if the afterContext is undefined. - * - TODO: This field can be removed once IncludePropertyValues is available in all regions. + * @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; beforeContext: any | undefined; - beforeContextBackup: PropertyNameMap; afterContext: any | undefined; - afterContextBackup: PropertyNameMap; } -export type PropertyNameMap = { Properties: {[propertyName: string]: string} }; export type PropertyReplacementModeMap = { [propertyName: string]: { replacementMode: ReplacementModes | undefined; @@ -385,10 +381,8 @@ export class DifferenceCollection> { return Object.values(this.changes).length; } - public get(logicalId: string): T { - const ret = this.diffs[logicalId]; - if (!ret) { throw new Error(`No object with logical ID '${logicalId}'`); } - return ret; + public get(logicalId: string): T | undefined { + return this.diffs[logicalId]; } public remove(logicalId: string): void { diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index e66be851555b2..44790a39c9ae0 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,997 +1,1012 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; -import { changeSet, changeSetWithMissingChanges, changeSetWithPartiallyFilledChanges, changeSetWithUndefinedDetails, ssmParam, sqsQueueWithAargs, changeSetWithIamChanges, ssmParamFromChangeset, queueFromChangeset } from './util'; -import { fullDiff, PropertyDifference, ResourceDifference, ResourceImpact, DifferenceCollection, Resource } from '../lib'; +import * as utils from './util'; +import { PropertyDifference, ResourceDifference, ResourceImpact, DifferenceCollection, Resource, ChangeSetResource } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; -describe('fullDiff tests that include changeset', () => { - test('changeset overrides spec replacements', () => { - // GIVEN - const currentTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'Name1' }, // Immutable prop - }, - }, - }; - const newTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: { Ref: 'BucketName' } }, // No change - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Parameters: [ - { - ParameterKey: 'BucketName', - ParameterValue: 'Name1', - }, - ], - Changes: [], - }); - - // THEN - expect(differences.differenceCount).toBe(0); - }); - - test('changeset does not overrides spec additions or deletions', () => { - // GIVEN - const currentTemplate = { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'MagicBucket' }, - }, - }, - }; - const newTemplate = { - Resources: { - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { QueueName: 'MagicQueue' }, - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - ResourceChange: { - Action: 'Remove', - LogicalResourceId: 'Bucket', - ResourceType: 'AWS::S3::Bucket', - Details: [], - }, - }, - { - ResourceChange: { - Action: 'Add', - LogicalResourceId: 'Queue', - ResourceType: 'AWS::SQS::Queue', - Details: [], - }, - }, - ], - }); - - // A realistic changeset will include Additions and Removals, but this shows that we don't use the changeset to determine additions or removals - const emptyChangeSetDifferences = fullDiff(currentTemplate, newTemplate, { - Changes: [], - }); - - // THEN - expect(differences.differenceCount).toBe(2); - expect(emptyChangeSetDifferences.differenceCount).toBe(2); - }); - - test('changeset replacements are respected', () => { - // GIVEN - const currentTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'Name1' }, // Immutable prop - }, - }, - }; - const newTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: { Ref: 'BucketName' } }, // 'Name1' -> 'Name2' - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Parameters: [ - { - ParameterKey: 'BucketName', - ParameterValue: 'Name2', - }, - ], - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'Bucket', - ResourceType: 'AWS::S3::Bucket', - Replacement: 'True', - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'BucketName', - RequiresRecreation: 'Always', - }, - Evaluation: 'Static', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - }); - - // THEN - expect(differences.differenceCount).toBe(1); - }); - - // This is directly in-line with changeset behavior, - // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html - test('dynamic changeset replacements are considered conditional replacements', () => { - // GIVEN - const currentTemplate = { - Resources: { - Instance: { - Type: 'AWS::EC2::Instance', - Properties: { - ImageId: 'ami-79fd7eee', - KeyName: 'rsa-is-fun', - }, - }, - }, - }; - const newTemplate = { - Resources: { - Instance: { - Type: 'AWS::EC2::Instance', - Properties: { - ImageId: 'ami-79fd7eee', - KeyName: 'but-sha-is-cool', - }, - }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'Instance', - ResourceType: 'AWS::EC2::Instance', - Replacement: 'Conditional', - Details: [ - { - Target: { - Attribute: 'Properties', - Name: 'KeyName', - RequiresRecreation: 'Always', - }, - Evaluation: 'Dynamic', - ChangeSource: 'DirectModification', - }, - ], - }, - }, - ], - }); - - // THEN - expect(differences.differenceCount).toBe(1); - expect(differences.resources.changes.Instance.changeImpact).toEqual(ResourceImpact.MAY_REPLACE); - expect(differences.resources.changes.Instance.propertyUpdates).toEqual({ - KeyName: { - changeImpact: ResourceImpact.MAY_REPLACE, - isDifferent: true, - oldValue: 'rsa-is-fun', - newValue: 'but-sha-is-cool', - }, - }); - }); - - test('changeset resource replacement is not tracked through references', () => { - // GIVEN - const currentTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'Name1' }, // Immutable prop - }, - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { QueueName: { Ref: 'Bucket' } }, // Immutable prop - }, - Topic: { - Type: 'AWS::SNS::Topic', - Properties: { TopicName: { Ref: 'Queue' } }, // Immutable prop - }, - }, - }; - - // WHEN - const newTemplate = { - Parameters: { - BucketName: { - Type: 'String', - }, - }, - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: { Ref: 'BucketName' } }, - }, - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { QueueName: { Ref: 'Bucket' } }, - }, - Topic: { - Type: 'AWS::SNS::Topic', - Properties: { TopicName: { Ref: 'Queue' } }, - }, - }, - }; - const differences = fullDiff(currentTemplate, newTemplate, { - Parameters: [ - { - ParameterKey: 'BucketName', - ParameterValue: 'Name1', - }, - ], - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'Bucket', - ResourceType: 'AWS::S3::Bucket', - Replacement: 'False', - Details: [], - }, - }, - ], - }); - - // THEN - expect(differences.resources.differenceCount).toBe(0); - }); - - test('Fn::GetAtt short form and long form are equivalent', () => { - // GIVEN - const currentTemplate = { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'BucketName' }, - }, - }, - Outputs: { - BucketArnOneWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, - BucketArnAnotherWay: { 'Fn::GetAtt': 'BucketName.Arn' }, - }, - }; - const newTemplate = { - Resources: { - Bucket: { - Type: 'AWS::S3::Bucket', - Properties: { BucketName: 'BucketName' }, - }, - }, - Outputs: { - BucketArnOneWay: { 'Fn::GetAtt': 'BucketName.Arn' }, - BucketArnAnotherWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, - }, - }; - - // WHEN - const differences = fullDiff(currentTemplate, newTemplate); - - // THEN - expect(differences.differenceCount).toBe(0); - }); - - test('metadata changes are obscured from the diff', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: 'magic-bucket', - Metadata: { - 'aws:cdk:path': '/foo/BucketResource', - }, - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: 'magic-bucket', - Metadata: { - 'aws:cdk:path': '/bar/BucketResource', - }, - }, - }, - }; - - // THEN - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.differenceCount).toBe(0); - }); - - test('single element arrays are equivalent to the single element in DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['SomeResource'], - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: 'SomeResource', - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - - differences = fullDiff(newTemplate, currentTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - }); - - test('array equivalence is independent of element order in DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['SomeResource', 'AnotherResource'], - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['AnotherResource', 'SomeResource'], - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - - differences = fullDiff(newTemplate, currentTemplate, {}); - expect(differences.resources.differenceCount).toBe(0); - }); - - test('arrays of different length are considered unequal in DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['SomeResource', 'AnotherResource', 'LastResource'], - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - DependsOn: ['AnotherResource', 'SomeResource'], - }, - }, - }; - - // dependsOn changes do not appear in the changeset - let differences = fullDiff(currentTemplate, newTemplate, {}); - expect(differences.resources.differenceCount).toBe(1); - - differences = fullDiff(newTemplate, currentTemplate, {}); - expect(differences.resources.differenceCount).toBe(1); - }); - - test('arrays that differ only in element order are considered unequal outside of DependsOn expressions', () => { - // GIVEN - const currentTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: { 'Fn::Select': [0, ['name1', 'name2']] }, - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - BucketName: { 'Fn::Select': [0, ['name2', 'name1']] }, - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'BucketResource', - ResourceType: 'AWS::S3::Bucket', - Replacement: 'True', - Details: [{ - Evaluation: 'Static', - Target: { - Attribute: 'Properties', - Name: 'BucketName', - RequiresRecreation: 'Always', - }, - }], - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - }); - - test('SAM Resources are rendered with changeset diffs', () => { - // GIVEN - const currentTemplate = { - Resources: { - ServerlessFunction: { - Type: 'AWS::Serverless::Function', - Properties: { - CodeUri: 's3://bermuda-triangle-1337-bucket/old-handler.zip', - }, - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - ServerlessFunction: { - Type: 'AWS::Serverless::Function', - Properties: { - CodeUri: 's3://bermuda-triangle-1337-bucket/new-handler.zip', - }, - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Modify', - LogicalResourceId: 'ServerlessFunction', - ResourceType: 'AWS::Lambda::Function', // The SAM transform is applied before the changeset is created, so the changeset has a Lambda resource here! - Replacement: 'False', - Details: [{ - Evaluation: 'Static', - Target: { - Attribute: 'Properties', - Name: 'Code', - RequiresRecreation: 'Never', - }, - }], - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - }); - - test('imports are respected for new stacks', async () => { - // GIVEN - const currentTemplate = {}; - - // WHEN - const newTemplate = { - Resources: { - BucketResource: { - Type: 'AWS::S3::Bucket', - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Import', - LogicalResourceId: 'BucketResource', - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); - }); - - test('imports are respected for existing stacks', async () => { - // GIVEN - const currentTemplate = { - Resources: { - OldResource: { - Type: 'AWS::Something::Resource', - }, - }, - }; - - // WHEN - const newTemplate = { - Resources: { - OldResource: { - Type: 'AWS::Something::Resource', - }, - BucketResource: { - Type: 'AWS::S3::Bucket', - }, - }, - }; - - let differences = fullDiff(currentTemplate, newTemplate, { - Changes: [ - { - Type: 'Resource', - ResourceChange: { - Action: 'Import', - LogicalResourceId: 'BucketResource', - }, - }, - ], - }); - expect(differences.resources.differenceCount).toBe(1); - expect(differences.resources.get('BucketResource').changeImpact === ResourceImpact.WILL_IMPORT); - }); - test('properties that only show up in changeset diff are included in fullDiff', () => { - // GIVEN - const currentTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - mySsmParameter: ssmParam, - }, - }; - - // WHEN - const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); - const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, - { Changes: [ssmParamFromChangeset] }, - ); - - // THEN - expect(diffWithoutChangeSet.differenceCount).toBe(0); - expect(diffWithoutChangeSet.resources.changes).toEqual({}); - - expect(diffWithChangeSet.differenceCount).toBe(1); - expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( - { - oldValue: { - Type: 'AWS::SSM::Parameter', - Properties: { - Value: 'changedddd', - Type: 'String', - Name: 'mySsmParameterFromStack', - }, - }, - newValue: { - Type: 'AWS::SSM::Parameter', - Properties: { - Value: 'sdflkja', - Type: 'String', - Name: 'mySsmParameterFromStack', - }, - }, - resourceTypes: { - oldType: 'AWS::SSM::Parameter', - newType: 'AWS::SSM::Parameter', - }, - propertyDiffs: { - Value: { - oldValue: 'changedddd', - newValue: 'sdflkja', - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - Type: { - oldValue: 'String', - newValue: 'String', - isDifferent: false, - changeImpact: 'NO_CHANGE', - }, - Name: { - oldValue: 'mySsmParameterFromStack', - newValue: 'mySsmParameterFromStack', - isDifferent: false, - changeImpact: 'NO_CHANGE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SSM::Parameter', - newValue: 'AWS::SSM::Parameter', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - ); - - expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); - }); - - test('resources that only show up in changeset diff are included in fullDiff', () => { - // GIVEN - const currentTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - Queue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: { - Ref: 'SsmParameterValuetestbugreportC9', - }, - }, - }, - }, - }; - - // WHEN - const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); - const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, - { - Changes: [ - queueFromChangeset({}), - ], - Parameters: [{ - ParameterKey: 'SsmParameterValuetestbugreportC9', - ParameterValue: 'goodJob', - ResolvedValue: 'changedVal', - }], - }, - ); - - // THEN - expect(diffWithoutChangeSet.differenceCount).toBe(0); - expect(diffWithoutChangeSet.resources.changes).toEqual({}); - - expect(diffWithChangeSet.differenceCount).toBe(1); - expect(diffWithChangeSet.resources.changes.Queue).toEqual( - { - oldValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: 'newValuechangedddd', - ReceiveMessageWaitTimeSeconds: '20', - }, - }, - newValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: 'newValuesdflkja', - ReceiveMessageWaitTimeSeconds: '20', - }, - }, - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: 'newValuechangedddd', - newValue: 'newValuesdflkja', - isDifferent: true, - changeImpact: 'WILL_REPLACE', - }, - ReceiveMessageWaitTimeSeconds: { - oldValue: '20', - newValue: '20', - isDifferent: false, - changeImpact: 'NO_CHANGE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - ); - - expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); - }); - - test('a resource in the diff that is missing a property has the missing property added to the diff', () => { - // The idea is, we detect 1 change in the template diff -- and we detect another change in the changeset diff. - - // GIVEN - const currentTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - Queue: sqsQueueWithAargs({ waitTime: 10, queueName: 'hi' }), - }, - }; - - const newTemplate = { - Parameters: { - SsmParameterValuetestbugreportC9: { - Type: 'AWS::SSM::Parameter::Value', - Default: 'goodJob', - }, - }, - Resources: { - Queue: sqsQueueWithAargs({ waitTime: 10, queueName: 'bye' }), - }, - }; - - // WHEN - const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); - const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, - { - Changes: [queueFromChangeset({ beforeContextWaitTime: '10', afterContextWaitTime: '20' })], - Parameters: [{ - ParameterKey: 'SsmParameterValuetestbugreportC9', - ParameterValue: 'goodJob', - ResolvedValue: 'changedddd', - }], - }, - ); - - // THEN - expect(diffWithoutChangeSet.differenceCount).toBe(1); - expect(diffWithoutChangeSet.resources.changes).toEqual( - { - Queue: { - oldValue: sqsQueueWithAargs({ waitTime: 10, queueName: 'hi' }), - newValue: sqsQueueWithAargs({ waitTime: 10, queueName: 'bye' }), - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: { - Ref: 'hi', - }, - newValue: { - Ref: 'bye', - }, - isDifferent: true, - changeImpact: 'WILL_REPLACE', - }, - ReceiveMessageWaitTimeSeconds: { - oldValue: 10, - newValue: 10, - isDifferent: false, - changeImpact: 'NO_CHANGE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - }, - ); - - expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed - expect(diffWithChangeSet.resources.changes.Queue).toEqual( - { - oldValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: { - Ref: 'hi', - }, - ReceiveMessageWaitTimeSeconds: 10, - }, - }, - newValue: { - Type: 'AWS::SQS::Queue', - Properties: { - QueueName: { - Ref: 'bye', - }, - ReceiveMessageWaitTimeSeconds: 10, - }, - }, - resourceTypes: { - oldType: 'AWS::SQS::Queue', - newType: 'AWS::SQS::Queue', - }, - propertyDiffs: { - QueueName: { - oldValue: { - Ref: 'hi', - }, - newValue: { - Ref: 'bye', - }, - isDifferent: true, - changeImpact: 'WILL_REPLACE', - }, - ReceiveMessageWaitTimeSeconds: { - oldValue: '10', - newValue: '20', - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - }, - otherDiffs: { - Type: { - oldValue: 'AWS::SQS::Queue', - newValue: 'AWS::SQS::Queue', - isDifferent: false, - }, - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }, - ); - }); - - test('IamChanges that are visible only through changeset are added to TemplatedDiff.iamChanges', () => { - // GIVEN - const currentTemplate = {}; - - // WHEN - const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, changeSetWithIamChanges); - - // THEN - expect(diffWithChangeSet.iamChanges.statements.additions).toEqual([{ - sid: undefined, - effect: 'Allow', - resources: { - values: [ - 'arn:aws:sqs:us-east-1:012345678901:newAndDifferent', - ], - not: false, - }, - actions: { - values: [ - 'sqs:DeleteMessage', - 'sqs:GetQueueAttributes', - 'sqs:ReceiveMessage', - 'sqs:SendMessage', - ], - not: false, - }, - principals: { - values: [ - 'AWS:{{changeSet:KNOWN_AFTER_APPLY}}', - ], - not: false, - }, - condition: undefined, - serializedIntrinsic: undefined, - }]); - - expect(diffWithChangeSet.iamChanges.statements.removals).toEqual([{ - sid: undefined, - effect: 'Allow', - resources: { - values: [ - 'arn:aws:sqs:us-east-1:012345678901:sdflkja', - ], - not: false, - }, - actions: { - values: [ - 'sqs:DeleteMessage', - 'sqs:GetQueueAttributes', - 'sqs:ReceiveMessage', - 'sqs:SendMessage', - ], - not: false, - }, - principals: { - values: [ - 'AWS:sdflkja', - ], - not: false, - }, - condition: undefined, - serializedIntrinsic: undefined, - }]); - - }); - -}); +// describe('fullDiff tests that include changeset', () => { +// test('changeset overrides spec replacements', () => { +// // GIVEN +// const currentTemplate = { +// Parameters: { +// BucketName: { +// Type: 'String', +// }, +// }, +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: 'Name1' }, // Immutable prop +// }, +// }, +// }; +// const newTemplate = { +// Parameters: { +// BucketName: { +// Type: 'String', +// }, +// }, +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: { Ref: 'BucketName' } }, // No change +// }, +// }, +// }; + +// // WHEN +// const differences = fullDiff(currentTemplate, newTemplate, { +// Parameters: [ +// { +// ParameterKey: 'BucketName', +// ParameterValue: 'Name1', +// }, +// ], +// Changes: [], +// }); + +// // THEN +// expect(differences.differenceCount).toBe(0); +// }); + +// test('changeset replacements are respected', () => { +// // GIVEN +// const currentTemplate = { +// Parameters: { +// BucketName: { +// Type: 'String', +// }, +// }, +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: 'Name1' }, // Immutable prop +// }, +// }, +// }; +// const newTemplate = { +// Parameters: { +// BucketName: { +// Type: 'String', +// }, +// }, +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: { Ref: 'BucketName' } }, // 'Name1' -> 'Name2' +// }, +// }, +// }; + +// // WHEN +// const differences = fullDiff(currentTemplate, newTemplate, { +// Parameters: [ +// { +// ParameterKey: 'BucketName', +// ParameterValue: 'Name2', +// }, +// ], +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Modify', +// LogicalResourceId: 'Bucket', +// ResourceType: 'AWS::S3::Bucket', +// Replacement: 'True', +// Details: [ +// { +// Target: { +// Attribute: 'Properties', +// Name: 'BucketName', +// RequiresRecreation: 'Always', +// }, +// Evaluation: 'Static', +// ChangeSource: 'DirectModification', +// }, +// ], +// }, +// }, +// ], +// }); + +// // THEN +// expect(differences.differenceCount).toBe(1); +// }); + +// // This is directly in-line with changeset behavior, +// // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html +// test('dynamic changeset replacements are considered conditional replacements', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// Instance: { +// Type: 'AWS::EC2::Instance', +// Properties: { +// ImageId: 'ami-79fd7eee', +// KeyName: 'rsa-is-fun', +// }, +// }, +// }, +// }; + +// const newTemplate = { +// Resources: { +// Instance: { +// Type: 'AWS::EC2::Instance', +// Properties: { +// ImageId: 'ami-79fd7eee', +// KeyName: 'but-sha-is-cool', +// }, +// }, +// }, +// }; + +// // WHEN +// const differences = fullDiff(currentTemplate, newTemplate, { +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Modify', +// LogicalResourceId: 'Instance', +// ResourceType: 'AWS::EC2::Instance', +// Replacement: 'Conditional', +// Details: [ +// { +// Target: { +// Attribute: 'Properties', +// Name: 'KeyName', +// RequiresRecreation: 'Always', +// }, +// Evaluation: 'Dynamic', +// ChangeSource: 'DirectModification', +// }, +// ], +// }, +// }, +// ], +// }); + +// // THEN +// expect(differences.differenceCount).toBe(1); +// expect(differences.resources.changes.Instance.changeImpact).toEqual(ResourceImpact.MAY_REPLACE); +// expect(differences.resources.changes.Instance.propertyUpdates).toEqual({ +// KeyName: { +// changeImpact: ResourceImpact.MAY_REPLACE, +// isDifferent: true, +// oldValue: 'rsa-is-fun', +// newValue: 'but-sha-is-cool', +// }, +// }); +// }); + +// test('changeset resource replacement is not tracked through references', () => { +// // GIVEN +// const currentTemplate = { +// Parameters: { +// BucketName: { +// Type: 'String', +// }, +// }, +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: 'Name1' }, // Immutable prop +// }, +// Queue: { +// Type: 'AWS::SQS::Queue', +// Properties: { QueueName: { Ref: 'Bucket' } }, // Immutable prop +// }, +// Topic: { +// Type: 'AWS::SNS::Topic', +// Properties: { TopicName: { Ref: 'Queue' } }, // Immutable prop +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Parameters: { +// BucketName: { +// Type: 'String', +// }, +// }, +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: { Ref: 'BucketName' } }, +// }, +// Queue: { +// Type: 'AWS::SQS::Queue', +// Properties: { QueueName: { Ref: 'Bucket' } }, +// }, +// Topic: { +// Type: 'AWS::SNS::Topic', +// Properties: { TopicName: { Ref: 'Queue' } }, +// }, +// }, +// }; +// const differences = fullDiff(currentTemplate, newTemplate, { +// Parameters: [ +// { +// ParameterKey: 'BucketName', +// ParameterValue: 'Name1', +// }, +// ], +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Modify', +// LogicalResourceId: 'Bucket', +// ResourceType: 'AWS::S3::Bucket', +// Replacement: 'False', +// Details: [], +// }, +// }, +// ], +// }); + +// // THEN +// expect(differences.resources.differenceCount).toBe(0); +// }); + +// test('Fn::GetAtt short form and long form are equivalent', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: 'BucketName' }, +// }, +// }, +// Outputs: { +// BucketArnOneWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, +// BucketArnAnotherWay: { 'Fn::GetAtt': 'BucketName.Arn' }, +// }, +// }; +// const newTemplate = { +// Resources: { +// Bucket: { +// Type: 'AWS::S3::Bucket', +// Properties: { BucketName: 'BucketName' }, +// }, +// }, +// Outputs: { +// BucketArnOneWay: { 'Fn::GetAtt': 'BucketName.Arn' }, +// BucketArnAnotherWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, +// }, +// }; + +// // WHEN +// const differences = fullDiff(currentTemplate, newTemplate); + +// // THEN +// expect(differences.differenceCount).toBe(0); +// }); + +// test('metadata changes are obscured from the diff', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// BucketName: 'magic-bucket', +// Metadata: { +// 'aws:cdk:path': '/foo/BucketResource', +// }, +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// BucketName: 'magic-bucket', +// Metadata: { +// 'aws:cdk:path': '/bar/BucketResource', +// }, +// }, +// }, +// }; + +// // THEN +// let differences = fullDiff(currentTemplate, newTemplate, {}); +// expect(differences.differenceCount).toBe(0); +// }); + +// test('single element arrays are equivalent to the single element in DependsOn expressions', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// DependsOn: ['SomeResource'], +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// DependsOn: 'SomeResource', +// }, +// }, +// }; + +// let differences = fullDiff(currentTemplate, newTemplate, {}); +// expect(differences.resources.differenceCount).toBe(0); + +// differences = fullDiff(newTemplate, currentTemplate, {}); +// expect(differences.resources.differenceCount).toBe(0); +// }); + +// test('array equivalence is independent of element order in DependsOn expressions', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// DependsOn: ['SomeResource', 'AnotherResource'], +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// DependsOn: ['AnotherResource', 'SomeResource'], +// }, +// }, +// }; + +// let differences = fullDiff(currentTemplate, newTemplate, {}); +// expect(differences.resources.differenceCount).toBe(0); + +// differences = fullDiff(newTemplate, currentTemplate, {}); +// expect(differences.resources.differenceCount).toBe(0); +// }); + +// test('arrays of different length are considered unequal in DependsOn expressions', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// DependsOn: ['SomeResource', 'AnotherResource', 'LastResource'], +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// DependsOn: ['AnotherResource', 'SomeResource'], +// }, +// }, +// }; + +// // dependsOn changes do not appear in the changeset +// let differences = fullDiff(currentTemplate, newTemplate, {}); +// expect(differences.resources.differenceCount).toBe(1); + +// differences = fullDiff(newTemplate, currentTemplate, {}); +// expect(differences.resources.differenceCount).toBe(1); +// }); + +// test('arrays that differ only in element order are considered unequal outside of DependsOn expressions', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// BucketName: { 'Fn::Select': [0, ['name1', 'name2']] }, +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// BucketName: { 'Fn::Select': [0, ['name2', 'name1']] }, +// }, +// }, +// }; + +// let differences = fullDiff(currentTemplate, newTemplate, { +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Modify', +// LogicalResourceId: 'BucketResource', +// ResourceType: 'AWS::S3::Bucket', +// Replacement: 'True', +// Details: [{ +// Evaluation: 'Static', +// Target: { +// Attribute: 'Properties', +// Name: 'BucketName', +// RequiresRecreation: 'Always', +// }, +// }], +// }, +// }, +// ], +// }); +// expect(differences.resources.differenceCount).toBe(1); +// }); + +// test('SAM Resources are rendered with changeset diffs', () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// ServerlessFunction: { +// Type: 'AWS::Serverless::Function', +// Properties: { +// CodeUri: 's3://bermuda-triangle-1337-bucket/old-handler.zip', +// }, +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// ServerlessFunction: { +// Type: 'AWS::Serverless::Function', +// Properties: { +// CodeUri: 's3://bermuda-triangle-1337-bucket/new-handler.zip', +// }, +// }, +// }, +// }; + +// let differences = fullDiff(currentTemplate, newTemplate, { +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Modify', +// LogicalResourceId: 'ServerlessFunction', +// ResourceType: 'AWS::Lambda::Function', // The SAM transform is applied before the changeset is created, so the changeset has a Lambda resource here! +// Replacement: 'False', +// Details: [{ +// Evaluation: 'Static', +// Target: { +// Attribute: 'Properties', +// Name: 'Code', +// RequiresRecreation: 'Never', +// }, +// }], +// }, +// }, +// ], +// }); +// expect(differences.resources.differenceCount).toBe(1); +// }); + +// test('imports are respected for new stacks', async () => { +// // GIVEN +// const currentTemplate = {}; + +// // WHEN +// const newTemplate = { +// Resources: { +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// }, +// }, +// }; + +// let differences = fullDiff(currentTemplate, newTemplate, { +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Import', +// LogicalResourceId: 'BucketResource', +// }, +// }, +// ], +// }); +// expect(differences.resources.differenceCount).toBe(1); +// expect(differences.resources.get('BucketResource')?.changeImpact === ResourceImpact.WILL_IMPORT); +// }); + +// test('imports are respected for existing stacks', async () => { +// // GIVEN +// const currentTemplate = { +// Resources: { +// OldResource: { +// Type: 'AWS::Something::Resource', +// }, +// }, +// }; + +// // WHEN +// const newTemplate = { +// Resources: { +// OldResource: { +// Type: 'AWS::Something::Resource', +// }, +// BucketResource: { +// Type: 'AWS::S3::Bucket', +// }, +// }, +// }; + +// let differences = fullDiff(currentTemplate, newTemplate, { +// Changes: [ +// { +// Type: 'Resource', +// ResourceChange: { +// Action: 'Import', +// LogicalResourceId: 'BucketResource', +// }, +// }, +// ], +// }); +// expect(differences.resources.differenceCount).toBe(1); +// expect(differences.resources.get('BucketResource')?.changeImpact === ResourceImpact.WILL_IMPORT); +// }); + +// test('properties that only show up in changeset diff are included in fullDiff', () => { +// // GIVEN +// const currentTemplate = { +// Parameters: { +// SsmParameterValuetestbugreportC9: { +// Type: 'AWS::SSM::Parameter::Value', +// Default: 'goodJob', +// }, +// }, +// Resources: { +// mySsmParameter: utils.ssmParam, +// }, +// }; + +// // WHEN +// const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); +// const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, +// { Changes: [utils.ssmParamFromChangeset] }, +// ); + +// // THEN +// expect(diffWithoutChangeSet.differenceCount).toBe(0); +// expect(diffWithoutChangeSet.resources.changes).toEqual({}); + +// expect(diffWithChangeSet.differenceCount).toBe(1); +// expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( +// { +// oldValue: { +// Type: 'AWS::SSM::Parameter', +// Properties: { +// Value: 'changedddd', +// Type: 'String', +// Name: 'mySsmParameterFromStack', +// }, +// Metadata: { +// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', +// }, +// }, +// newValue: { +// Type: 'AWS::SSM::Parameter', +// Properties: { +// Value: 'sdflkja', +// Type: 'String', +// Name: 'mySsmParameterFromStack', +// }, +// Metadata: { +// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', +// }, +// }, +// resourceTypes: { +// oldType: 'AWS::SSM::Parameter', +// newType: 'AWS::SSM::Parameter', +// }, +// propertyDiffs: { +// Value: { +// oldValue: 'changedddd', +// newValue: 'sdflkja', +// isDifferent: true, +// changeImpact: 'WILL_UPDATE', +// }, +// Type: { +// oldValue: 'String', +// newValue: 'String', +// isDifferent: false, +// changeImpact: 'NO_CHANGE', +// }, +// Name: { +// oldValue: 'mySsmParameterFromStack', +// newValue: 'mySsmParameterFromStack', +// isDifferent: false, +// changeImpact: 'NO_CHANGE', +// }, +// }, +// otherDiffs: { +// Type: { +// oldValue: 'AWS::SSM::Parameter', +// newValue: 'AWS::SSM::Parameter', +// isDifferent: false, +// }, +// Metadata: { +// oldValue: { +// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', +// }, +// newValue: { +// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', +// }, +// isDifferent: false, +// }, +// }, +// isAddition: false, +// isRemoval: false, +// isImport: undefined, +// }, +// ); + +// expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); +// }); + +// test('resources that only show up in changeset diff are included in fullDiff', () => { +// // GIVEN +// const currentTemplate = { +// Parameters: { +// SsmParameterValuetestbugreportC9: { +// Type: 'AWS::SSM::Parameter::Value', +// Default: 'goodJob', +// }, +// }, +// Resources: { +// Queue: { +// Type: 'AWS::SQS::Queue', +// Properties: { +// QueueName: { +// Ref: 'SsmParameterValuetestbugreportC9', +// }, +// }, +// }, +// }, +// }; + +// // WHEN +// const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); +// const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, +// { +// Changes: [ +// utils.queueFromChangeset({}), +// ], +// Parameters: [{ +// ParameterKey: 'SsmParameterValuetestbugreportC9', +// ParameterValue: 'goodJob', +// ResolvedValue: 'changedVal', +// }], +// }, +// ); + +// // THEN +// expect(diffWithoutChangeSet.differenceCount).toBe(0); +// expect(diffWithoutChangeSet.resources.changes).toEqual({}); + +// expect(diffWithChangeSet.differenceCount).toBe(1); +// expect(diffWithChangeSet.resources.changes.Queue).toEqual( +// { +// oldValue: { +// Type: 'AWS::SQS::Queue', +// Properties: { +// QueueName: 'newValuechangedddd', +// ReceiveMessageWaitTimeSeconds: '20', +// }, +// Metadata: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// UpdateReplacePolicy: 'Delete', +// DeletionPolicy: 'Delete', +// }, +// newValue: { +// Type: 'AWS::SQS::Queue', +// Properties: { +// QueueName: 'newValuesdflkja', +// ReceiveMessageWaitTimeSeconds: '20', +// }, +// Metadata: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// UpdateReplacePolicy: 'Delete', +// DeletionPolicy: 'Delete', +// }, +// resourceTypes: { +// oldType: 'AWS::SQS::Queue', +// newType: 'AWS::SQS::Queue', +// }, +// propertyDiffs: { +// QueueName: { +// oldValue: 'newValuechangedddd', +// newValue: 'newValuesdflkja', +// isDifferent: true, +// changeImpact: 'WILL_REPLACE', +// }, +// ReceiveMessageWaitTimeSeconds: { +// oldValue: '20', +// newValue: '20', +// isDifferent: false, +// changeImpact: 'NO_CHANGE', +// }, +// }, +// otherDiffs: { +// Type: { +// oldValue: 'AWS::SQS::Queue', +// newValue: 'AWS::SQS::Queue', +// isDifferent: false, +// }, +// Metadata: { +// oldValue: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// newValue: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// isDifferent: false, +// }, +// UpdateReplacePolicy: { +// oldValue: 'Delete', +// newValue: 'Delete', +// isDifferent: false, +// }, +// DeletionPolicy: { +// oldValue: 'Delete', +// newValue: 'Delete', +// isDifferent: false, +// }, +// }, +// isAddition: false, +// isRemoval: false, +// isImport: undefined, +// }, +// ); + +// expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); +// }); + +// test('changeSet diff properties override the TemplateDiff properties', () => { + +// // GIVEN +// const currentTemplate = { +// Parameters: { +// SsmParameterValuetestbugreportC9: { +// Type: 'AWS::SSM::Parameter::Value', +// Default: 'goodJob', +// }, +// }, +// Resources: { +// Queue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'hi' }), +// }, +// }; + +// const newTemplate = { +// Parameters: { +// SsmParameterValuetestbugreportC9: { +// Type: 'AWS::SSM::Parameter::Value', +// Default: 'goodJob', +// }, +// }, +// Resources: { +// Queue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'bye' }), +// }, +// }; + +// // WHEN +// const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); +// const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, +// { +// Changes: [utils.queueFromChangeset({ beforeContextWaitTime: '10', afterContextWaitTime: '20' })], +// Parameters: [{ +// ParameterKey: 'SsmParameterValuetestbugreportC9', +// ParameterValue: 'goodJob', +// ResolvedValue: 'changedddd', +// }], +// }, +// ); + +// // THEN +// expect(diffWithoutChangeSet.differenceCount).toBe(1); +// expect(diffWithoutChangeSet.resources.changes).toEqual( +// { +// Queue: { +// oldValue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'hi' }), +// newValue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'bye' }), +// resourceTypes: { +// oldType: 'AWS::SQS::Queue', +// newType: 'AWS::SQS::Queue', +// }, +// propertyDiffs: { +// QueueName: { +// oldValue: { +// Ref: 'hi', +// }, +// newValue: { +// Ref: 'bye', +// }, +// isDifferent: true, +// changeImpact: 'WILL_REPLACE', +// }, +// ReceiveMessageWaitTimeSeconds: { +// oldValue: 10, +// newValue: 10, +// isDifferent: false, +// changeImpact: 'NO_CHANGE', +// }, +// }, +// otherDiffs: { +// Type: { +// oldValue: 'AWS::SQS::Queue', +// newValue: 'AWS::SQS::Queue', +// isDifferent: false, +// }, +// }, +// isAddition: false, +// isRemoval: false, +// isImport: undefined, +// }, +// }, +// ); + +// expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed +// expect(diffWithChangeSet.resources.changes.Queue).toEqual( +// { +// oldValue: { +// Type: 'AWS::SQS::Queue', +// Properties: { +// QueueName: 'newValuechangedddd', +// ReceiveMessageWaitTimeSeconds: '10', +// }, +// Metadata: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// UpdateReplacePolicy: 'Delete', +// DeletionPolicy: 'Delete', +// }, +// newValue: { +// Type: 'AWS::SQS::Queue', +// Properties: { +// QueueName: 'newValuesdflkja', +// ReceiveMessageWaitTimeSeconds: '20', +// }, +// Metadata: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// UpdateReplacePolicy: 'Delete', +// DeletionPolicy: 'Delete', +// }, +// resourceTypes: { +// oldType: 'AWS::SQS::Queue', +// newType: 'AWS::SQS::Queue', +// }, +// propertyDiffs: { +// QueueName: { +// oldValue: 'newValuechangedddd', +// newValue: 'newValuesdflkja', +// isDifferent: true, +// changeImpact: 'WILL_REPLACE', +// }, +// ReceiveMessageWaitTimeSeconds: { +// oldValue: '10', +// newValue: '20', +// isDifferent: true, +// changeImpact: 'WILL_UPDATE', +// }, +// }, +// otherDiffs: { +// Type: { +// oldValue: 'AWS::SQS::Queue', +// newValue: 'AWS::SQS::Queue', +// isDifferent: false, +// }, +// Metadata: { +// oldValue: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// newValue: { +// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', +// }, +// isDifferent: false, +// }, +// UpdateReplacePolicy: { +// oldValue: 'Delete', +// newValue: 'Delete', +// isDifferent: false, +// }, +// DeletionPolicy: { +// oldValue: 'Delete', +// newValue: 'Delete', +// isDifferent: false, +// }, +// }, +// isAddition: false, +// isRemoval: false, +// isImport: undefined, +// }, +// ); +// }); + +// test('IamChanges that are visible only through changeset are added to TemplatedDiff.iamChanges', () => { +// // GIVEN +// const currentTemplate = {}; + +// // WHEN +// const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, utils.changeSetWithIamChanges); + +// // THEN +// expect(diffWithChangeSet.iamChanges.statements.additions).toEqual([{ +// sid: undefined, +// effect: 'Allow', +// resources: { +// values: [ +// 'arn:aws:sqs:us-east-1:012345678901:newAndDifferent', +// ], +// not: false, +// }, +// actions: { +// values: [ +// 'sqs:DeleteMessage', +// 'sqs:GetQueueAttributes', +// 'sqs:ReceiveMessage', +// 'sqs:SendMessage', +// ], +// not: false, +// }, +// principals: { +// values: [ +// 'AWS:{{changeSet:KNOWN_AFTER_APPLY}}', +// ], +// not: false, +// }, +// condition: undefined, +// serializedIntrinsic: undefined, +// }]); + +// expect(diffWithChangeSet.iamChanges.statements.removals).toEqual([{ +// sid: undefined, +// effect: 'Allow', +// resources: { +// values: [ +// 'arn:aws:sqs:us-east-1:012345678901:sdflkja', +// ], +// not: false, +// }, +// actions: { +// values: [ +// 'sqs:DeleteMessage', +// 'sqs:GetQueueAttributes', +// 'sqs:ReceiveMessage', +// 'sqs:SendMessage', +// ], +// not: false, +// }, +// principals: { +// values: [ +// 'AWS:sdflkja', +// ], +// not: false, +// }, +// condition: undefined, +// serializedIntrinsic: undefined, +// }]); + +// }); + +// }); describe('method tests', () => { @@ -999,7 +1014,7 @@ describe('method tests', () => { test('InspectChangeSet correctly parses changeset', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSet }); // THEN expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); @@ -1007,12 +1022,35 @@ describe('method tests', () => { resourceWasReplaced: true, resourceType: 'AWS::SQS::Queue', propertyReplacementModes: { + ReceiveMessageWaitTimeSeconds: { + replacementMode: 'Never', + }, QueueName: { replacementMode: 'Always', - beforeValue: 'newValuechangedddd', - afterValue: 'newValuesdflkja', }, }, + beforeContext: { + Properties: { + QueueName: 'newValuechangedddd', + ReceiveMessageWaitTimeSeconds: '20', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, + afterContext: { + Properties: { + QueueName: 'newValuesdflkja', + ReceiveMessageWaitTimeSeconds: '20', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, }); expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ resourceWasReplaced: false, @@ -1020,8 +1058,26 @@ describe('method tests', () => { propertyReplacementModes: { Value: { replacementMode: 'Never', - beforeValue: 'changedddd', - afterValue: 'sdflkja', + }, + }, + beforeContext: { + Properties: { + Value: 'changedddd', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + }, + afterContext: { + Properties: { + Value: 'sdflkja', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', }, }, }); @@ -1038,24 +1094,48 @@ describe('method tests', () => { test('TemplateAndChangeSetDiffMerger constructor can handle undefined changes in changset.Changes', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSetWithMissingChanges }); // THEN expect(templateAndChangeSetDiffMerger.changeSetResources).toEqual({}); - expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithMissingChanges); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(utils.changeSetWithMissingChanges); }); test('TemplateAndChangeSetDiffMerger constructor can handle partially defined changes in changset.Changes', async () => { - // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithPartiallyFilledChanges }); + // WHEN + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSetWithPartiallyFilledChanges }); // THEN - expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithPartiallyFilledChanges); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(utils.changeSetWithPartiallyFilledChanges); expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(2); - expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual({ + expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).mySsmParameter).toEqual( { resourceWasReplaced: false, - resourceType: 'AWS::SSM::Parameter', - properties: {}, + resourceType: 'UNKNOWN_RESOURCE_TYPE', + propertyReplacementModes: { + Value: { + replacementMode: 'Never', + }, + }, + beforeContext: { + Properties: { + Value: 'changedddd', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + }, + afterContext: { + Properties: { + Value: 'sdflkja', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + }, }); expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ resourceWasReplaced: true, @@ -1063,24 +1143,47 @@ describe('method tests', () => { propertyReplacementModes: { QueueName: { replacementMode: 'Always', - beforeValue: undefined, - afterValue: undefined, }, }, + beforeContext: { + Properties: { + QueueName: undefined, + ReceiveMessageWaitTimeSeconds: '20', + Random: 'nice', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, + afterContext: { + Properties: { + QueueName: undefined, + ReceiveMessageWaitTimeSeconds: '20', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, }); }); test('TemplateAndChangeSetDiffMerger constructor can handle undefined Details in changset.Changes', async () => { // WHEN - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSetWithUndefinedDetails }); // THEN - expect(templateAndChangeSetDiffMerger.changeSet).toEqual(changeSetWithUndefinedDetails); + expect(templateAndChangeSetDiffMerger.changeSet).toEqual(utils.changeSetWithUndefinedDetails); expect(Object.keys(templateAndChangeSetDiffMerger.changeSetResources ?? {}).length).toBe(1); expect((templateAndChangeSetDiffMerger.changeSetResources ?? {}).Queue).toEqual({ resourceWasReplaced: true, resourceType: 'UNKNOWN_RESOURCE_TYPE', - properties: {}, + propertyReplacementModes: {}, + beforeContext: undefined, + afterContext: undefined, }); }); @@ -1159,87 +1262,10 @@ describe('method tests', () => { describe('overrideDiffResourcesWithChangeSetResources', () => { - test('can add resources that have template changes and changeset changes', async () => { - // GIVEN - const resources = new DifferenceCollection( - { - Queue: new ResourceDifference( - { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'first' } }, - { Type: 'AWS::SQS::QUEUE', Properties: { QueueName: 'second' } }, - { - resourceType: { oldType: 'AWS::SQS::QUEUE', newType: 'AWS::SQS::QUEUE' }, - propertyDiffs: { QueueName: new PropertyDifference( 'first', 'second', { changeImpact: ResourceImpact.WILL_UPDATE }) }, - otherDiffs: {}, - }, - ), - }, - ); - - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ - changeSet: {}, - changeSetResources: { - Queue: { - propertyReplacementModes: { - DelaySeconds: { - replacementMode: 'Conditionally', - afterValue: 10, - beforeValue: 2, - }, - }, - } as any, - }, - }); - - //WHEN - templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); - - // THEN - expect(resources.differenceCount).toBe(1); - expect(resources.changes.Queue.isUpdate).toBe(true); - expect(resources.changes.Queue).toEqual({ - oldValue: { - Type: 'AWS::SQS::QUEUE', - Properties: { - QueueName: 'first', - }, - }, - newValue: { - Type: 'AWS::SQS::QUEUE', - Properties: { - QueueName: 'second', - }, - }, - resourceTypes: { - oldType: 'AWS::SQS::QUEUE', - newType: 'AWS::SQS::QUEUE', - }, - propertyDiffs: { - QueueName: { - oldValue: 'first', - newValue: 'second', - isDifferent: true, - changeImpact: 'WILL_UPDATE', - }, - DelaySeconds: { - oldValue: 2, - newValue: 10, - isDifferent: true, - changeImpact: undefined, - }, - }, - otherDiffs: { - }, - isAddition: false, - isRemoval: false, - isImport: undefined, - }); - - }); - test('can add resources from changeset', async () => { // GIVEN const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSet }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSet }); //WHEN templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); @@ -1255,6 +1281,9 @@ describe('method tests', () => { Type: 'String', Name: 'mySsmParameterFromStack', }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, }, newValue: { Type: 'AWS::SSM::Parameter', @@ -1263,6 +1292,9 @@ describe('method tests', () => { Type: 'String', Name: 'mySsmParameterFromStack', }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, }, resourceTypes: { oldType: 'AWS::SSM::Parameter', @@ -1294,6 +1326,15 @@ describe('method tests', () => { newValue: 'AWS::SSM::Parameter', isDifferent: false, }, + Metadata: { + oldValue: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + newValue: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + isDifferent: false, + }, }, isAddition: false, isRemoval: false, @@ -1308,6 +1349,11 @@ describe('method tests', () => { QueueName: 'newValuechangedddd', ReceiveMessageWaitTimeSeconds: '20', }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', }, newValue: { Type: 'AWS::SQS::Queue', @@ -1315,6 +1361,11 @@ describe('method tests', () => { QueueName: 'newValuesdflkja', ReceiveMessageWaitTimeSeconds: '20', }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', }, resourceTypes: { oldType: 'AWS::SQS::Queue', @@ -1340,6 +1391,25 @@ describe('method tests', () => { newValue: 'AWS::SQS::Queue', isDifferent: false, }, + Metadata: { + oldValue: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + newValue: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + isDifferent: false, + }, + UpdateReplacePolicy: { + oldValue: 'Delete', + newValue: 'Delete', + isDifferent: false, + }, + DeletionPolicy: { + oldValue: 'Delete', + newValue: 'Delete', + isDifferent: false, + }, }, isAddition: false, isRemoval: false, @@ -1350,7 +1420,7 @@ describe('method tests', () => { test('can add resources from empty changeset', async () => { // GIVEN const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithMissingChanges }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSetWithMissingChanges }); //WHEN templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); @@ -1364,20 +1434,129 @@ describe('method tests', () => { test('can add resources from changeset that have undefined resourceType and Details', async () => { // GIVEN const resources = new DifferenceCollection({}); - const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: changeSetWithUndefinedDetails }); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + resourceWasReplaced: false, + resourceType: undefined, + propertyReplacementModes: {}, + beforeContext: { Properties: { QueueName: 'hi' } }, + afterContext: { Properties: { QueueName: 'bye' } }, + } as ChangeSetResource, + }, + }); //WHEN templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); // THEN - expect(resources.differenceCount).toBe(0); - expect(resources.changes).toEqual({}); + expect(resources.differenceCount).toBe(1); + expect(resources.changes.Queue).toEqual({ + oldValue: { + Type: 'UNKNOWN_RESOURCE_TYPE', + Properties: { + QueueName: 'hi', + }, + }, + newValue: { + Type: 'UNKNOWN_RESOURCE_TYPE', + Properties: { + QueueName: 'bye', + }, + }, + resourceTypes: { + oldType: 'UNKNOWN_RESOURCE_TYPE', + newType: 'UNKNOWN_RESOURCE_TYPE', + }, + propertyDiffs: { + QueueName: { + oldValue: 'hi', + newValue: 'bye', + isDifferent: true, + changeImpact: 'NO_CHANGE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'UNKNOWN_RESOURCE_TYPE', + newValue: 'UNKNOWN_RESOURCE_TYPE', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); + + }); + + test('works without beforeContext and afterContext', async () => { + // GIVEN + const resources = new DifferenceCollection({}); + const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ + changeSet: {}, + changeSetResources: { + Queue: { + propertyReplacementModes: { + QueueName: { replacementMode: 'Always' }, + DelaySeconds: { replacementMode: 'Never' }, + }, + resourceWasReplaced: false, + resourceType: 'AWS::SQS::Queue', + beforeContext: undefined, + afterContext: undefined, + } as ChangeSetResource, + }, + }); + + //WHEN + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); + + // THEN + expect(resources.differenceCount).toBe(1); + expect(resources.changes.Queue).toEqual({ + oldValue: { + Type: 'UNKNOWN_RESOURCE_TYPE', + Properties: { + QueueName: 'hi', + }, + }, + newValue: { + Type: 'UNKNOWN_RESOURCE_TYPE', + Properties: { + QueueName: 'bye', + }, + }, + resourceTypes: { + oldType: 'UNKNOWN_RESOURCE_TYPE', + newType: 'UNKNOWN_RESOURCE_TYPE', + }, + propertyDiffs: { + QueueName: { + oldValue: 'hi', + newValue: 'bye', + isDifferent: true, + changeImpact: 'NO_CHANGE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'UNKNOWN_RESOURCE_TYPE', + newValue: 'UNKNOWN_RESOURCE_TYPE', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); }); }); - describe('hydrateChangeImpactFromChangeset', () => { + describe('overrideDiffResourceChangeImpactWithChangeSetChangeImpact', () => { test('can handle blank change', async () => { // GIVEN @@ -1646,10 +1825,6 @@ describe('method tests', () => { expect(queue.isDifferent).toBe(true); }); - test('Can handle old and new resourceType being UNKNOWN (diffResource might not like it)', async () => { - throw new Error('f'); - }); - }); }); diff --git a/packages/@aws-cdk/cloudformation-diff/test/util.ts b/packages/@aws-cdk/cloudformation-diff/test/util.ts index 92f14232ce3e1..1c0782e28ecd4 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/util.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/util.ts @@ -86,7 +86,7 @@ export const ssmParam = { }, }; -export function sqsQueueWithAargs(args: { waitTime: number; queueName?: string }) { +export function sqsQueueWithArgs(args: { waitTime: number; queueName?: string }) { return { Type: 'AWS::SQS::Queue', Properties: { @@ -226,7 +226,7 @@ copyOfQueueChange.ResourceChange.BeforeContext = beforeContext; export const changeSetWithPartiallyFilledChanges: DescribeChangeSetOutput = { Changes: [ - ssmParamFromChangeset, + copyOfssmChange, copyOfQueueChange, ], }; From 213df93c45fd597424cc50dd3edb84a44c95f2cd Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Thu, 23 May 2024 21:33:12 -0400 Subject: [PATCH 34/36] ready --- .../template-and-changeset-diff-merger.ts | 90 +- .../test/diff-template.test.ts | 8 + ...template-and-changeset-diff-merger.test.ts | 2157 +++++++++-------- 3 files changed, 1194 insertions(+), 1061 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index dbcb5a721fbd5..d4e283c14a6f3 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -14,6 +14,47 @@ export class TemplateAndChangeSetDiffMerger { // If we somehow cannot find the resourceType, then we'll mark it as UNKNOWN, so that can be seen in the diff. static UNKNOWN_RESOURCE_TYPE = 'UNKNOWN_RESOURCE_TYPE'; + /** + * TODO: Once IncludePropertyValues is supported in all regions, this function can be deleted + */ + public static convertContextlessChangeSetResourceToResource( + changeSetResourceResourceType: string | undefined, + oldOrNewValueFromTemplateDiff: types.Resource | undefined, + args: { + propertiesThatChanged: string[]; + beforeOrAfterChanges: 'BEFORE' | 'AFTER'; + }, + ): types.Resource { + const backupMessage = args.beforeOrAfterChanges === 'AFTER' ? 'value_after_change_is_not_viewable' : 'value_before_change_is_not_viewable'; + const resourceExistsInTemplateDiff = oldOrNewValueFromTemplateDiff !== undefined; + if (resourceExistsInTemplateDiff) { + // if resourceExistsInTemplateDiff, then we don't want to erase the details of property changes that are in the template diff -- but we want + // to make sure all changes from the ChangeSet are mentioned. At this point, since BeforeContext and AfterContext were not available from the + // ChangeSet, we can't get the before and after values of the properties from the changeset. + // So, the best we can do for the properties that aren't in the template diff is mention that they'll be changing. + + if (oldOrNewValueFromTemplateDiff?.Properties === undefined) { + oldOrNewValueFromTemplateDiff.Properties = {}; + } + + // write properties from changeset that are missing from the template diff + for (const propertyName of args.propertiesThatChanged) { + if (!(propertyName in oldOrNewValueFromTemplateDiff.Properties)) { + oldOrNewValueFromTemplateDiff.Properties[propertyName] = backupMessage; + } + } + return oldOrNewValueFromTemplateDiff; + } else { + // The resource didn't change in the templateDiff but is mentioned in the changeset. E.g., perhaps because an ssm parameter, that defined a property, changed value. + const propsWithBackUpMessage: { [propertyName: string]: string } = {}; + for (const propName of args.propertiesThatChanged) { propsWithBackUpMessage[propName] = backupMessage; } + return { + Type: changeSetResourceResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, + Properties: propsWithBackUpMessage, + }; + } + } + 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. @@ -107,12 +148,12 @@ export class TemplateAndChangeSetDiffMerger { }; } else { // TODO -- once IncludePropertyValues is supported in all regions for changesets, delete this else branch. Only above case will occur. - oldResource = this.convertContextlessChangeSetResourceToResource( + oldResource = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( changeSetResource.resourceType, resourceDiffs.get(logicalIdFromChangeSet)?.oldValue, { propertiesThatChanged: Object.keys(changeSetResource.propertyReplacementModes || {}), - oldOrNew: 'OLD', + beforeOrAfterChanges: 'BEFORE', }, ); } @@ -126,12 +167,12 @@ export class TemplateAndChangeSetDiffMerger { }; } else { // TODO -- once IncludePropertyValues is supported in all regions for changesets, delete this else branch. Only above case will occur. - newResource = this.convertContextlessChangeSetResourceToResource( + newResource = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( changeSetResource.resourceType, resourceDiffs.get(logicalIdFromChangeSet)?.newValue, { propertiesThatChanged: Object.keys(changeSetResource.propertyReplacementModes || {}), - oldOrNew: 'NEW', + beforeOrAfterChanges: 'AFTER', }, ); } @@ -141,47 +182,6 @@ export class TemplateAndChangeSetDiffMerger { } } - /** - * TODO: Once IncludePropertyValues is supported in all regions, this function can be deleted - */ - public convertContextlessChangeSetResourceToResource( - changeSetResourceResourceType: string | undefined, - oldOrNewValueFromTemplateDiff: types.Resource | undefined, - args: { - propertiesThatChanged: string[]; - oldOrNew: 'OLD' | 'NEW'; - }, - ): types.Resource { - const backupMessage = args.oldOrNew === 'NEW' ? 'value_after_change_is_not_viewable' : 'value_before_change_is_not_viewable'; - const resourceExistsInTemplateDiff = oldOrNewValueFromTemplateDiff !== undefined; - if (resourceExistsInTemplateDiff) { - // if resourceExistsInTemplateDiff, then we don't want to erase the details of property changes that are in the template diff -- but we want - // to make sure all changes from the ChangeSet are mentioned. At this point, since BeforeContext and AfterContext were not available from the - // ChangeSet, we can't get the before and after values of the properties from the changeset. - // So, the best we can do for the properties that aren't in the template diff is mention that they'll be changing. - - if (oldOrNewValueFromTemplateDiff?.Properties === undefined) { - oldOrNewValueFromTemplateDiff.Properties = {}; - } - - // write properties from changeset that are missing from the template diff - for (const propertyName of args.propertiesThatChanged) { - if (!(propertyName in oldOrNewValueFromTemplateDiff.Properties)) { - oldOrNewValueFromTemplateDiff.Properties[propertyName] = backupMessage; - } - } - return oldOrNewValueFromTemplateDiff; - } else { - // The resource didn't change in the templateDiff but is mentioned in the changeset. E.g., perhaps because an ssm parameter, that defined a property, changed value. - const propsWithBackUpMessage: { [propertyName: string]: string } = {}; - for (const propName of args.propertiesThatChanged) { propsWithBackUpMessage[propName] = backupMessage; } - return { - Type: changeSetResourceResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, - Properties: propsWithBackUpMessage, - }; - } - } - public overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId: string, change: types.ResourceDifference) { // resourceType getter throws an error if resourceTypeChanged if ((change.resourceTypeChanged === true) || change.resourceType?.includes('AWS::Serverless')) { diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index 78c68d474f5b8..cfc505b16c346 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -11,6 +11,14 @@ const BUCKET_POLICY_RESOURCE = { }, }; +describe('diffResource', () => { + + test('properties that are not different are not added to the diffs', () => { + + }); + +}); + test('when there is no difference', () => { const bucketName = 'ShineyBucketName'; const currentTemplate = { diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 44790a39c9ae0..3c5be80481023 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1,1012 +1,1012 @@ import { ResourceChangeDetail } from '@aws-sdk/client-cloudformation'; import * as utils from './util'; -import { PropertyDifference, ResourceDifference, ResourceImpact, DifferenceCollection, Resource, ChangeSetResource } from '../lib'; +import { PropertyDifference, ResourceDifference, ResourceImpact, DifferenceCollection, Resource, ChangeSetResource, fullDiff } from '../lib'; import { TemplateAndChangeSetDiffMerger } from '../lib/diff/template-and-changeset-diff-merger'; -// describe('fullDiff tests that include changeset', () => { -// test('changeset overrides spec replacements', () => { -// // GIVEN -// const currentTemplate = { -// Parameters: { -// BucketName: { -// Type: 'String', -// }, -// }, -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: 'Name1' }, // Immutable prop -// }, -// }, -// }; -// const newTemplate = { -// Parameters: { -// BucketName: { -// Type: 'String', -// }, -// }, -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: { Ref: 'BucketName' } }, // No change -// }, -// }, -// }; - -// // WHEN -// const differences = fullDiff(currentTemplate, newTemplate, { -// Parameters: [ -// { -// ParameterKey: 'BucketName', -// ParameterValue: 'Name1', -// }, -// ], -// Changes: [], -// }); - -// // THEN -// expect(differences.differenceCount).toBe(0); -// }); - -// test('changeset replacements are respected', () => { -// // GIVEN -// const currentTemplate = { -// Parameters: { -// BucketName: { -// Type: 'String', -// }, -// }, -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: 'Name1' }, // Immutable prop -// }, -// }, -// }; -// const newTemplate = { -// Parameters: { -// BucketName: { -// Type: 'String', -// }, -// }, -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: { Ref: 'BucketName' } }, // 'Name1' -> 'Name2' -// }, -// }, -// }; - -// // WHEN -// const differences = fullDiff(currentTemplate, newTemplate, { -// Parameters: [ -// { -// ParameterKey: 'BucketName', -// ParameterValue: 'Name2', -// }, -// ], -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Modify', -// LogicalResourceId: 'Bucket', -// ResourceType: 'AWS::S3::Bucket', -// Replacement: 'True', -// Details: [ -// { -// Target: { -// Attribute: 'Properties', -// Name: 'BucketName', -// RequiresRecreation: 'Always', -// }, -// Evaluation: 'Static', -// ChangeSource: 'DirectModification', -// }, -// ], -// }, -// }, -// ], -// }); - -// // THEN -// expect(differences.differenceCount).toBe(1); -// }); - -// // This is directly in-line with changeset behavior, -// // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html -// test('dynamic changeset replacements are considered conditional replacements', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// Instance: { -// Type: 'AWS::EC2::Instance', -// Properties: { -// ImageId: 'ami-79fd7eee', -// KeyName: 'rsa-is-fun', -// }, -// }, -// }, -// }; - -// const newTemplate = { -// Resources: { -// Instance: { -// Type: 'AWS::EC2::Instance', -// Properties: { -// ImageId: 'ami-79fd7eee', -// KeyName: 'but-sha-is-cool', -// }, -// }, -// }, -// }; - -// // WHEN -// const differences = fullDiff(currentTemplate, newTemplate, { -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Modify', -// LogicalResourceId: 'Instance', -// ResourceType: 'AWS::EC2::Instance', -// Replacement: 'Conditional', -// Details: [ -// { -// Target: { -// Attribute: 'Properties', -// Name: 'KeyName', -// RequiresRecreation: 'Always', -// }, -// Evaluation: 'Dynamic', -// ChangeSource: 'DirectModification', -// }, -// ], -// }, -// }, -// ], -// }); - -// // THEN -// expect(differences.differenceCount).toBe(1); -// expect(differences.resources.changes.Instance.changeImpact).toEqual(ResourceImpact.MAY_REPLACE); -// expect(differences.resources.changes.Instance.propertyUpdates).toEqual({ -// KeyName: { -// changeImpact: ResourceImpact.MAY_REPLACE, -// isDifferent: true, -// oldValue: 'rsa-is-fun', -// newValue: 'but-sha-is-cool', -// }, -// }); -// }); - -// test('changeset resource replacement is not tracked through references', () => { -// // GIVEN -// const currentTemplate = { -// Parameters: { -// BucketName: { -// Type: 'String', -// }, -// }, -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: 'Name1' }, // Immutable prop -// }, -// Queue: { -// Type: 'AWS::SQS::Queue', -// Properties: { QueueName: { Ref: 'Bucket' } }, // Immutable prop -// }, -// Topic: { -// Type: 'AWS::SNS::Topic', -// Properties: { TopicName: { Ref: 'Queue' } }, // Immutable prop -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Parameters: { -// BucketName: { -// Type: 'String', -// }, -// }, -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: { Ref: 'BucketName' } }, -// }, -// Queue: { -// Type: 'AWS::SQS::Queue', -// Properties: { QueueName: { Ref: 'Bucket' } }, -// }, -// Topic: { -// Type: 'AWS::SNS::Topic', -// Properties: { TopicName: { Ref: 'Queue' } }, -// }, -// }, -// }; -// const differences = fullDiff(currentTemplate, newTemplate, { -// Parameters: [ -// { -// ParameterKey: 'BucketName', -// ParameterValue: 'Name1', -// }, -// ], -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Modify', -// LogicalResourceId: 'Bucket', -// ResourceType: 'AWS::S3::Bucket', -// Replacement: 'False', -// Details: [], -// }, -// }, -// ], -// }); - -// // THEN -// expect(differences.resources.differenceCount).toBe(0); -// }); - -// test('Fn::GetAtt short form and long form are equivalent', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: 'BucketName' }, -// }, -// }, -// Outputs: { -// BucketArnOneWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, -// BucketArnAnotherWay: { 'Fn::GetAtt': 'BucketName.Arn' }, -// }, -// }; -// const newTemplate = { -// Resources: { -// Bucket: { -// Type: 'AWS::S3::Bucket', -// Properties: { BucketName: 'BucketName' }, -// }, -// }, -// Outputs: { -// BucketArnOneWay: { 'Fn::GetAtt': 'BucketName.Arn' }, -// BucketArnAnotherWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, -// }, -// }; - -// // WHEN -// const differences = fullDiff(currentTemplate, newTemplate); - -// // THEN -// expect(differences.differenceCount).toBe(0); -// }); - -// test('metadata changes are obscured from the diff', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// BucketName: 'magic-bucket', -// Metadata: { -// 'aws:cdk:path': '/foo/BucketResource', -// }, -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// BucketName: 'magic-bucket', -// Metadata: { -// 'aws:cdk:path': '/bar/BucketResource', -// }, -// }, -// }, -// }; - -// // THEN -// let differences = fullDiff(currentTemplate, newTemplate, {}); -// expect(differences.differenceCount).toBe(0); -// }); - -// test('single element arrays are equivalent to the single element in DependsOn expressions', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// DependsOn: ['SomeResource'], -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// DependsOn: 'SomeResource', -// }, -// }, -// }; - -// let differences = fullDiff(currentTemplate, newTemplate, {}); -// expect(differences.resources.differenceCount).toBe(0); - -// differences = fullDiff(newTemplate, currentTemplate, {}); -// expect(differences.resources.differenceCount).toBe(0); -// }); - -// test('array equivalence is independent of element order in DependsOn expressions', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// DependsOn: ['SomeResource', 'AnotherResource'], -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// DependsOn: ['AnotherResource', 'SomeResource'], -// }, -// }, -// }; - -// let differences = fullDiff(currentTemplate, newTemplate, {}); -// expect(differences.resources.differenceCount).toBe(0); - -// differences = fullDiff(newTemplate, currentTemplate, {}); -// expect(differences.resources.differenceCount).toBe(0); -// }); - -// test('arrays of different length are considered unequal in DependsOn expressions', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// DependsOn: ['SomeResource', 'AnotherResource', 'LastResource'], -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// DependsOn: ['AnotherResource', 'SomeResource'], -// }, -// }, -// }; - -// // dependsOn changes do not appear in the changeset -// let differences = fullDiff(currentTemplate, newTemplate, {}); -// expect(differences.resources.differenceCount).toBe(1); - -// differences = fullDiff(newTemplate, currentTemplate, {}); -// expect(differences.resources.differenceCount).toBe(1); -// }); - -// test('arrays that differ only in element order are considered unequal outside of DependsOn expressions', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// BucketName: { 'Fn::Select': [0, ['name1', 'name2']] }, -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// BucketName: { 'Fn::Select': [0, ['name2', 'name1']] }, -// }, -// }, -// }; - -// let differences = fullDiff(currentTemplate, newTemplate, { -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Modify', -// LogicalResourceId: 'BucketResource', -// ResourceType: 'AWS::S3::Bucket', -// Replacement: 'True', -// Details: [{ -// Evaluation: 'Static', -// Target: { -// Attribute: 'Properties', -// Name: 'BucketName', -// RequiresRecreation: 'Always', -// }, -// }], -// }, -// }, -// ], -// }); -// expect(differences.resources.differenceCount).toBe(1); -// }); - -// test('SAM Resources are rendered with changeset diffs', () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// ServerlessFunction: { -// Type: 'AWS::Serverless::Function', -// Properties: { -// CodeUri: 's3://bermuda-triangle-1337-bucket/old-handler.zip', -// }, -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// ServerlessFunction: { -// Type: 'AWS::Serverless::Function', -// Properties: { -// CodeUri: 's3://bermuda-triangle-1337-bucket/new-handler.zip', -// }, -// }, -// }, -// }; - -// let differences = fullDiff(currentTemplate, newTemplate, { -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Modify', -// LogicalResourceId: 'ServerlessFunction', -// ResourceType: 'AWS::Lambda::Function', // The SAM transform is applied before the changeset is created, so the changeset has a Lambda resource here! -// Replacement: 'False', -// Details: [{ -// Evaluation: 'Static', -// Target: { -// Attribute: 'Properties', -// Name: 'Code', -// RequiresRecreation: 'Never', -// }, -// }], -// }, -// }, -// ], -// }); -// expect(differences.resources.differenceCount).toBe(1); -// }); - -// test('imports are respected for new stacks', async () => { -// // GIVEN -// const currentTemplate = {}; - -// // WHEN -// const newTemplate = { -// Resources: { -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// }, -// }, -// }; - -// let differences = fullDiff(currentTemplate, newTemplate, { -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Import', -// LogicalResourceId: 'BucketResource', -// }, -// }, -// ], -// }); -// expect(differences.resources.differenceCount).toBe(1); -// expect(differences.resources.get('BucketResource')?.changeImpact === ResourceImpact.WILL_IMPORT); -// }); - -// test('imports are respected for existing stacks', async () => { -// // GIVEN -// const currentTemplate = { -// Resources: { -// OldResource: { -// Type: 'AWS::Something::Resource', -// }, -// }, -// }; - -// // WHEN -// const newTemplate = { -// Resources: { -// OldResource: { -// Type: 'AWS::Something::Resource', -// }, -// BucketResource: { -// Type: 'AWS::S3::Bucket', -// }, -// }, -// }; - -// let differences = fullDiff(currentTemplate, newTemplate, { -// Changes: [ -// { -// Type: 'Resource', -// ResourceChange: { -// Action: 'Import', -// LogicalResourceId: 'BucketResource', -// }, -// }, -// ], -// }); -// expect(differences.resources.differenceCount).toBe(1); -// expect(differences.resources.get('BucketResource')?.changeImpact === ResourceImpact.WILL_IMPORT); -// }); - -// test('properties that only show up in changeset diff are included in fullDiff', () => { -// // GIVEN -// const currentTemplate = { -// Parameters: { -// SsmParameterValuetestbugreportC9: { -// Type: 'AWS::SSM::Parameter::Value', -// Default: 'goodJob', -// }, -// }, -// Resources: { -// mySsmParameter: utils.ssmParam, -// }, -// }; - -// // WHEN -// const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); -// const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, -// { Changes: [utils.ssmParamFromChangeset] }, -// ); - -// // THEN -// expect(diffWithoutChangeSet.differenceCount).toBe(0); -// expect(diffWithoutChangeSet.resources.changes).toEqual({}); - -// expect(diffWithChangeSet.differenceCount).toBe(1); -// expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( -// { -// oldValue: { -// Type: 'AWS::SSM::Parameter', -// Properties: { -// Value: 'changedddd', -// Type: 'String', -// Name: 'mySsmParameterFromStack', -// }, -// Metadata: { -// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', -// }, -// }, -// newValue: { -// Type: 'AWS::SSM::Parameter', -// Properties: { -// Value: 'sdflkja', -// Type: 'String', -// Name: 'mySsmParameterFromStack', -// }, -// Metadata: { -// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', -// }, -// }, -// resourceTypes: { -// oldType: 'AWS::SSM::Parameter', -// newType: 'AWS::SSM::Parameter', -// }, -// propertyDiffs: { -// Value: { -// oldValue: 'changedddd', -// newValue: 'sdflkja', -// isDifferent: true, -// changeImpact: 'WILL_UPDATE', -// }, -// Type: { -// oldValue: 'String', -// newValue: 'String', -// isDifferent: false, -// changeImpact: 'NO_CHANGE', -// }, -// Name: { -// oldValue: 'mySsmParameterFromStack', -// newValue: 'mySsmParameterFromStack', -// isDifferent: false, -// changeImpact: 'NO_CHANGE', -// }, -// }, -// otherDiffs: { -// Type: { -// oldValue: 'AWS::SSM::Parameter', -// newValue: 'AWS::SSM::Parameter', -// isDifferent: false, -// }, -// Metadata: { -// oldValue: { -// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', -// }, -// newValue: { -// 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', -// }, -// isDifferent: false, -// }, -// }, -// isAddition: false, -// isRemoval: false, -// isImport: undefined, -// }, -// ); - -// expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); -// }); - -// test('resources that only show up in changeset diff are included in fullDiff', () => { -// // GIVEN -// const currentTemplate = { -// Parameters: { -// SsmParameterValuetestbugreportC9: { -// Type: 'AWS::SSM::Parameter::Value', -// Default: 'goodJob', -// }, -// }, -// Resources: { -// Queue: { -// Type: 'AWS::SQS::Queue', -// Properties: { -// QueueName: { -// Ref: 'SsmParameterValuetestbugreportC9', -// }, -// }, -// }, -// }, -// }; - -// // WHEN -// const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); -// const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, -// { -// Changes: [ -// utils.queueFromChangeset({}), -// ], -// Parameters: [{ -// ParameterKey: 'SsmParameterValuetestbugreportC9', -// ParameterValue: 'goodJob', -// ResolvedValue: 'changedVal', -// }], -// }, -// ); - -// // THEN -// expect(diffWithoutChangeSet.differenceCount).toBe(0); -// expect(diffWithoutChangeSet.resources.changes).toEqual({}); - -// expect(diffWithChangeSet.differenceCount).toBe(1); -// expect(diffWithChangeSet.resources.changes.Queue).toEqual( -// { -// oldValue: { -// Type: 'AWS::SQS::Queue', -// Properties: { -// QueueName: 'newValuechangedddd', -// ReceiveMessageWaitTimeSeconds: '20', -// }, -// Metadata: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// UpdateReplacePolicy: 'Delete', -// DeletionPolicy: 'Delete', -// }, -// newValue: { -// Type: 'AWS::SQS::Queue', -// Properties: { -// QueueName: 'newValuesdflkja', -// ReceiveMessageWaitTimeSeconds: '20', -// }, -// Metadata: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// UpdateReplacePolicy: 'Delete', -// DeletionPolicy: 'Delete', -// }, -// resourceTypes: { -// oldType: 'AWS::SQS::Queue', -// newType: 'AWS::SQS::Queue', -// }, -// propertyDiffs: { -// QueueName: { -// oldValue: 'newValuechangedddd', -// newValue: 'newValuesdflkja', -// isDifferent: true, -// changeImpact: 'WILL_REPLACE', -// }, -// ReceiveMessageWaitTimeSeconds: { -// oldValue: '20', -// newValue: '20', -// isDifferent: false, -// changeImpact: 'NO_CHANGE', -// }, -// }, -// otherDiffs: { -// Type: { -// oldValue: 'AWS::SQS::Queue', -// newValue: 'AWS::SQS::Queue', -// isDifferent: false, -// }, -// Metadata: { -// oldValue: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// newValue: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// isDifferent: false, -// }, -// UpdateReplacePolicy: { -// oldValue: 'Delete', -// newValue: 'Delete', -// isDifferent: false, -// }, -// DeletionPolicy: { -// oldValue: 'Delete', -// newValue: 'Delete', -// isDifferent: false, -// }, -// }, -// isAddition: false, -// isRemoval: false, -// isImport: undefined, -// }, -// ); - -// expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); -// }); - -// test('changeSet diff properties override the TemplateDiff properties', () => { - -// // GIVEN -// const currentTemplate = { -// Parameters: { -// SsmParameterValuetestbugreportC9: { -// Type: 'AWS::SSM::Parameter::Value', -// Default: 'goodJob', -// }, -// }, -// Resources: { -// Queue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'hi' }), -// }, -// }; - -// const newTemplate = { -// Parameters: { -// SsmParameterValuetestbugreportC9: { -// Type: 'AWS::SSM::Parameter::Value', -// Default: 'goodJob', -// }, -// }, -// Resources: { -// Queue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'bye' }), -// }, -// }; - -// // WHEN -// const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); -// const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, -// { -// Changes: [utils.queueFromChangeset({ beforeContextWaitTime: '10', afterContextWaitTime: '20' })], -// Parameters: [{ -// ParameterKey: 'SsmParameterValuetestbugreportC9', -// ParameterValue: 'goodJob', -// ResolvedValue: 'changedddd', -// }], -// }, -// ); - -// // THEN -// expect(diffWithoutChangeSet.differenceCount).toBe(1); -// expect(diffWithoutChangeSet.resources.changes).toEqual( -// { -// Queue: { -// oldValue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'hi' }), -// newValue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'bye' }), -// resourceTypes: { -// oldType: 'AWS::SQS::Queue', -// newType: 'AWS::SQS::Queue', -// }, -// propertyDiffs: { -// QueueName: { -// oldValue: { -// Ref: 'hi', -// }, -// newValue: { -// Ref: 'bye', -// }, -// isDifferent: true, -// changeImpact: 'WILL_REPLACE', -// }, -// ReceiveMessageWaitTimeSeconds: { -// oldValue: 10, -// newValue: 10, -// isDifferent: false, -// changeImpact: 'NO_CHANGE', -// }, -// }, -// otherDiffs: { -// Type: { -// oldValue: 'AWS::SQS::Queue', -// newValue: 'AWS::SQS::Queue', -// isDifferent: false, -// }, -// }, -// isAddition: false, -// isRemoval: false, -// isImport: undefined, -// }, -// }, -// ); - -// expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed -// expect(diffWithChangeSet.resources.changes.Queue).toEqual( -// { -// oldValue: { -// Type: 'AWS::SQS::Queue', -// Properties: { -// QueueName: 'newValuechangedddd', -// ReceiveMessageWaitTimeSeconds: '10', -// }, -// Metadata: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// UpdateReplacePolicy: 'Delete', -// DeletionPolicy: 'Delete', -// }, -// newValue: { -// Type: 'AWS::SQS::Queue', -// Properties: { -// QueueName: 'newValuesdflkja', -// ReceiveMessageWaitTimeSeconds: '20', -// }, -// Metadata: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// UpdateReplacePolicy: 'Delete', -// DeletionPolicy: 'Delete', -// }, -// resourceTypes: { -// oldType: 'AWS::SQS::Queue', -// newType: 'AWS::SQS::Queue', -// }, -// propertyDiffs: { -// QueueName: { -// oldValue: 'newValuechangedddd', -// newValue: 'newValuesdflkja', -// isDifferent: true, -// changeImpact: 'WILL_REPLACE', -// }, -// ReceiveMessageWaitTimeSeconds: { -// oldValue: '10', -// newValue: '20', -// isDifferent: true, -// changeImpact: 'WILL_UPDATE', -// }, -// }, -// otherDiffs: { -// Type: { -// oldValue: 'AWS::SQS::Queue', -// newValue: 'AWS::SQS::Queue', -// isDifferent: false, -// }, -// Metadata: { -// oldValue: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// newValue: { -// 'aws:cdk:path': 'cdkbugreport/Queue/Resource', -// }, -// isDifferent: false, -// }, -// UpdateReplacePolicy: { -// oldValue: 'Delete', -// newValue: 'Delete', -// isDifferent: false, -// }, -// DeletionPolicy: { -// oldValue: 'Delete', -// newValue: 'Delete', -// isDifferent: false, -// }, -// }, -// isAddition: false, -// isRemoval: false, -// isImport: undefined, -// }, -// ); -// }); - -// test('IamChanges that are visible only through changeset are added to TemplatedDiff.iamChanges', () => { -// // GIVEN -// const currentTemplate = {}; - -// // WHEN -// const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, utils.changeSetWithIamChanges); - -// // THEN -// expect(diffWithChangeSet.iamChanges.statements.additions).toEqual([{ -// sid: undefined, -// effect: 'Allow', -// resources: { -// values: [ -// 'arn:aws:sqs:us-east-1:012345678901:newAndDifferent', -// ], -// not: false, -// }, -// actions: { -// values: [ -// 'sqs:DeleteMessage', -// 'sqs:GetQueueAttributes', -// 'sqs:ReceiveMessage', -// 'sqs:SendMessage', -// ], -// not: false, -// }, -// principals: { -// values: [ -// 'AWS:{{changeSet:KNOWN_AFTER_APPLY}}', -// ], -// not: false, -// }, -// condition: undefined, -// serializedIntrinsic: undefined, -// }]); - -// expect(diffWithChangeSet.iamChanges.statements.removals).toEqual([{ -// sid: undefined, -// effect: 'Allow', -// resources: { -// values: [ -// 'arn:aws:sqs:us-east-1:012345678901:sdflkja', -// ], -// not: false, -// }, -// actions: { -// values: [ -// 'sqs:DeleteMessage', -// 'sqs:GetQueueAttributes', -// 'sqs:ReceiveMessage', -// 'sqs:SendMessage', -// ], -// not: false, -// }, -// principals: { -// values: [ -// 'AWS:sdflkja', -// ], -// not: false, -// }, -// condition: undefined, -// serializedIntrinsic: undefined, -// }]); - -// }); - -// }); +describe('fullDiff tests that include changeset', () => { + test('changeset overrides spec replacements', () => { + // GIVEN + const currentTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'Name1' }, // Immutable prop + }, + }, + }; + const newTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'BucketName' } }, // No change + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Parameters: [ + { + ParameterKey: 'BucketName', + ParameterValue: 'Name1', + }, + ], + Changes: [], + }); + + // THEN + expect(differences.differenceCount).toBe(0); + }); + + test('changeset replacements are respected', () => { + // GIVEN + const currentTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'Name1' }, // Immutable prop + }, + }, + }; + const newTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'BucketName' } }, // 'Name1' -> 'Name2' + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Parameters: [ + { + ParameterKey: 'BucketName', + ParameterValue: 'Name2', + }, + ], + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Bucket', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'True', + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'BucketName', + RequiresRecreation: 'Always', + }, + Evaluation: 'Static', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], + }); + + // THEN + expect(differences.differenceCount).toBe(1); + }); + + // This is directly in-line with changeset behavior, + // see 'Replacement': https://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_ResourceChange.html + test('dynamic changeset replacements are considered conditional replacements', () => { + // GIVEN + const currentTemplate = { + Resources: { + Instance: { + Type: 'AWS::EC2::Instance', + Properties: { + ImageId: 'ami-79fd7eee', + KeyName: 'rsa-is-fun', + }, + }, + }, + }; + + const newTemplate = { + Resources: { + Instance: { + Type: 'AWS::EC2::Instance', + Properties: { + ImageId: 'ami-79fd7eee', + KeyName: 'but-sha-is-cool', + }, + }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Instance', + ResourceType: 'AWS::EC2::Instance', + Replacement: 'Conditional', + Details: [ + { + Target: { + Attribute: 'Properties', + Name: 'KeyName', + RequiresRecreation: 'Always', + }, + Evaluation: 'Dynamic', + ChangeSource: 'DirectModification', + }, + ], + }, + }, + ], + }); + + // THEN + expect(differences.differenceCount).toBe(1); + expect(differences.resources.changes.Instance.changeImpact).toEqual(ResourceImpact.MAY_REPLACE); + expect(differences.resources.changes.Instance.propertyUpdates).toEqual({ + KeyName: { + changeImpact: ResourceImpact.MAY_REPLACE, + isDifferent: true, + oldValue: 'rsa-is-fun', + newValue: 'but-sha-is-cool', + }, + }); + }); + + test('changeset resource replacement is not tracked through references', () => { + // GIVEN + const currentTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'Name1' }, // Immutable prop + }, + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { QueueName: { Ref: 'Bucket' } }, // Immutable prop + }, + Topic: { + Type: 'AWS::SNS::Topic', + Properties: { TopicName: { Ref: 'Queue' } }, // Immutable prop + }, + }, + }; + + // WHEN + const newTemplate = { + Parameters: { + BucketName: { + Type: 'String', + }, + }, + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: { Ref: 'BucketName' } }, + }, + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { QueueName: { Ref: 'Bucket' } }, + }, + Topic: { + Type: 'AWS::SNS::Topic', + Properties: { TopicName: { Ref: 'Queue' } }, + }, + }, + }; + const differences = fullDiff(currentTemplate, newTemplate, { + Parameters: [ + { + ParameterKey: 'BucketName', + ParameterValue: 'Name1', + }, + ], + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Bucket', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'False', + Details: [], + }, + }, + ], + }); + + // THEN + expect(differences.resources.differenceCount).toBe(0); + }); + + test('Fn::GetAtt short form and long form are equivalent', () => { + // GIVEN + const currentTemplate = { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'BucketName' }, + }, + }, + Outputs: { + BucketArnOneWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, + BucketArnAnotherWay: { 'Fn::GetAtt': 'BucketName.Arn' }, + }, + }; + const newTemplate = { + Resources: { + Bucket: { + Type: 'AWS::S3::Bucket', + Properties: { BucketName: 'BucketName' }, + }, + }, + Outputs: { + BucketArnOneWay: { 'Fn::GetAtt': 'BucketName.Arn' }, + BucketArnAnotherWay: { 'Fn::GetAtt': ['BucketName', 'Arn'] }, + }, + }; + + // WHEN + const differences = fullDiff(currentTemplate, newTemplate); + + // THEN + expect(differences.differenceCount).toBe(0); + }); + + test('metadata changes are obscured from the diff', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: 'magic-bucket', + Metadata: { + 'aws:cdk:path': '/foo/BucketResource', + }, + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: 'magic-bucket', + Metadata: { + 'aws:cdk:path': '/bar/BucketResource', + }, + }, + }, + }; + + // THEN + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.differenceCount).toBe(0); + }); + + test('single element arrays are equivalent to the single element in DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['SomeResource'], + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: 'SomeResource', + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + + differences = fullDiff(newTemplate, currentTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + }); + + test('array equivalence is independent of element order in DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['SomeResource', 'AnotherResource'], + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['AnotherResource', 'SomeResource'], + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + + differences = fullDiff(newTemplate, currentTemplate, {}); + expect(differences.resources.differenceCount).toBe(0); + }); + + test('arrays of different length are considered unequal in DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['SomeResource', 'AnotherResource', 'LastResource'], + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + DependsOn: ['AnotherResource', 'SomeResource'], + }, + }, + }; + + // dependsOn changes do not appear in the changeset + let differences = fullDiff(currentTemplate, newTemplate, {}); + expect(differences.resources.differenceCount).toBe(1); + + differences = fullDiff(newTemplate, currentTemplate, {}); + expect(differences.resources.differenceCount).toBe(1); + }); + + test('arrays that differ only in element order are considered unequal outside of DependsOn expressions', () => { + // GIVEN + const currentTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: { 'Fn::Select': [0, ['name1', 'name2']] }, + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + BucketName: { 'Fn::Select': [0, ['name2', 'name1']] }, + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'BucketResource', + ResourceType: 'AWS::S3::Bucket', + Replacement: 'True', + Details: [{ + Evaluation: 'Static', + Target: { + Attribute: 'Properties', + Name: 'BucketName', + RequiresRecreation: 'Always', + }, + }], + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + }); + + test('SAM Resources are rendered with changeset diffs', () => { + // GIVEN + const currentTemplate = { + Resources: { + ServerlessFunction: { + Type: 'AWS::Serverless::Function', + Properties: { + CodeUri: 's3://bermuda-triangle-1337-bucket/old-handler.zip', + }, + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + ServerlessFunction: { + Type: 'AWS::Serverless::Function', + Properties: { + CodeUri: 's3://bermuda-triangle-1337-bucket/new-handler.zip', + }, + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'ServerlessFunction', + ResourceType: 'AWS::Lambda::Function', // The SAM transform is applied before the changeset is created, so the changeset has a Lambda resource here! + Replacement: 'False', + Details: [{ + Evaluation: 'Static', + Target: { + Attribute: 'Properties', + Name: 'Code', + RequiresRecreation: 'Never', + }, + }], + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + }); + + test('imports are respected for new stacks', async () => { + // GIVEN + const currentTemplate = {}; + + // WHEN + const newTemplate = { + Resources: { + BucketResource: { + Type: 'AWS::S3::Bucket', + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'BucketResource', + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + expect(differences.resources.get('BucketResource')?.changeImpact === ResourceImpact.WILL_IMPORT); + }); + + test('imports are respected for existing stacks', async () => { + // GIVEN + const currentTemplate = { + Resources: { + OldResource: { + Type: 'AWS::Something::Resource', + }, + }, + }; + + // WHEN + const newTemplate = { + Resources: { + OldResource: { + Type: 'AWS::Something::Resource', + }, + BucketResource: { + Type: 'AWS::S3::Bucket', + }, + }, + }; + + let differences = fullDiff(currentTemplate, newTemplate, { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Import', + LogicalResourceId: 'BucketResource', + }, + }, + ], + }); + expect(differences.resources.differenceCount).toBe(1); + expect(differences.resources.get('BucketResource')?.changeImpact === ResourceImpact.WILL_IMPORT); + }); + + test('properties that only show up in changeset diff are included in fullDiff', () => { + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + mySsmParameter: utils.ssmParam, + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, + { Changes: [utils.ssmParamFromChangeset] }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(0); + expect(diffWithoutChangeSet.resources.changes).toEqual({}); + + expect(diffWithChangeSet.differenceCount).toBe(1); + expect(diffWithChangeSet.resources.changes.mySsmParameter).toEqual( + { + oldValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'changedddd', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + }, + newValue: { + Type: 'AWS::SSM::Parameter', + Properties: { + Value: 'sdflkja', + Type: 'String', + Name: 'mySsmParameterFromStack', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + }, + resourceTypes: { + oldType: 'AWS::SSM::Parameter', + newType: 'AWS::SSM::Parameter', + }, + propertyDiffs: { + Value: { + oldValue: 'changedddd', + newValue: 'sdflkja', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + Type: { + oldValue: 'String', + newValue: 'String', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + Name: { + oldValue: 'mySsmParameterFromStack', + newValue: 'mySsmParameterFromStack', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SSM::Parameter', + newValue: 'AWS::SSM::Parameter', + isDifferent: false, + }, + Metadata: { + oldValue: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + newValue: { + 'aws:cdk:path': 'cdkbugreport/mySsmParameter/Resource', + }, + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + ); + + expect(diffWithChangeSet.resources.changes.mySsmParameter.isUpdate).toEqual(true); + }); + + test('resources that only show up in changeset diff are included in fullDiff', () => { + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + Ref: 'SsmParameterValuetestbugreportC9', + }, + }, + }, + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, currentTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, + { + Changes: [ + utils.queueFromChangeset({}), + ], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedVal', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(0); + expect(diffWithoutChangeSet.resources.changes).toEqual({}); + + expect(diffWithChangeSet.differenceCount).toBe(1); + expect(diffWithChangeSet.resources.changes.Queue).toEqual( + { + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuechangedddd', + ReceiveMessageWaitTimeSeconds: '20', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuesdflkja', + ReceiveMessageWaitTimeSeconds: '20', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: 'newValuechangedddd', + newValue: 'newValuesdflkja', + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: '20', + newValue: '20', + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + Metadata: { + oldValue: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + newValue: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + isDifferent: false, + }, + UpdateReplacePolicy: { + oldValue: 'Delete', + newValue: 'Delete', + isDifferent: false, + }, + DeletionPolicy: { + oldValue: 'Delete', + newValue: 'Delete', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + ); + + expect(diffWithChangeSet.resources.changes.Queue.isUpdate).toEqual(true); + }); + + test('changeSet diff properties override the TemplateDiff properties', () => { + + // GIVEN + const currentTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'hi' }), + }, + }; + + const newTemplate = { + Parameters: { + SsmParameterValuetestbugreportC9: { + Type: 'AWS::SSM::Parameter::Value', + Default: 'goodJob', + }, + }, + Resources: { + Queue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'bye' }), + }, + }; + + // WHEN + const diffWithoutChangeSet = fullDiff(currentTemplate, newTemplate); + const diffWithChangeSet = fullDiff(currentTemplate, newTemplate, + { + Changes: [utils.queueFromChangeset({ beforeContextWaitTime: '10', afterContextWaitTime: '20' })], + Parameters: [{ + ParameterKey: 'SsmParameterValuetestbugreportC9', + ParameterValue: 'goodJob', + ResolvedValue: 'changedddd', + }], + }, + ); + + // THEN + expect(diffWithoutChangeSet.differenceCount).toBe(1); + expect(diffWithoutChangeSet.resources.changes).toEqual( + { + Queue: { + oldValue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'hi' }), + newValue: utils.sqsQueueWithArgs({ waitTime: 10, queueName: 'bye' }), + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: { + Ref: 'hi', + }, + newValue: { + Ref: 'bye', + }, + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: 10, + newValue: 10, + isDifferent: false, + changeImpact: 'NO_CHANGE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + }, + ); + + expect(diffWithChangeSet.differenceCount).toBe(1); // this is the count of how many resources have changed + expect(diffWithChangeSet.resources.changes.Queue).toEqual( + { + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuechangedddd', + ReceiveMessageWaitTimeSeconds: '10', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'newValuesdflkja', + ReceiveMessageWaitTimeSeconds: '20', + }, + Metadata: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + UpdateReplacePolicy: 'Delete', + DeletionPolicy: 'Delete', + }, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + QueueName: { + oldValue: 'newValuechangedddd', + newValue: 'newValuesdflkja', + isDifferent: true, + changeImpact: 'WILL_REPLACE', + }, + ReceiveMessageWaitTimeSeconds: { + oldValue: '10', + newValue: '20', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + Metadata: { + oldValue: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + newValue: { + 'aws:cdk:path': 'cdkbugreport/Queue/Resource', + }, + isDifferent: false, + }, + UpdateReplacePolicy: { + oldValue: 'Delete', + newValue: 'Delete', + isDifferent: false, + }, + DeletionPolicy: { + oldValue: 'Delete', + newValue: 'Delete', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }, + ); + }); + + test('IamChanges that are visible only through changeset are added to TemplatedDiff.iamChanges', () => { + // GIVEN + const currentTemplate = {}; + + // WHEN + const diffWithChangeSet = fullDiff(currentTemplate, currentTemplate, utils.changeSetWithIamChanges); + + // THEN + expect(diffWithChangeSet.iamChanges.statements.additions).toEqual([{ + sid: undefined, + effect: 'Allow', + resources: { + values: [ + 'arn:aws:sqs:us-east-1:012345678901:newAndDifferent', + ], + not: false, + }, + actions: { + values: [ + 'sqs:DeleteMessage', + 'sqs:GetQueueAttributes', + 'sqs:ReceiveMessage', + 'sqs:SendMessage', + ], + not: false, + }, + principals: { + values: [ + 'AWS:{{changeSet:KNOWN_AFTER_APPLY}}', + ], + not: false, + }, + condition: undefined, + serializedIntrinsic: undefined, + }]); + + expect(diffWithChangeSet.iamChanges.statements.removals).toEqual([{ + sid: undefined, + effect: 'Allow', + resources: { + values: [ + 'arn:aws:sqs:us-east-1:012345678901:sdflkja', + ], + not: false, + }, + actions: { + values: [ + 'sqs:DeleteMessage', + 'sqs:GetQueueAttributes', + 'sqs:ReceiveMessage', + 'sqs:SendMessage', + ], + not: false, + }, + principals: { + values: [ + 'AWS:sdflkja', + ], + not: false, + }, + condition: undefined, + serializedIntrinsic: undefined, + }]); + + }); + +}); describe('method tests', () => { @@ -1517,33 +1517,41 @@ describe('method tests', () => { expect(resources.differenceCount).toBe(1); expect(resources.changes.Queue).toEqual({ oldValue: { - Type: 'UNKNOWN_RESOURCE_TYPE', + Type: 'AWS::SQS::Queue', Properties: { - QueueName: 'hi', + QueueName: 'value_before_change_is_not_viewable', + DelaySeconds: 'value_before_change_is_not_viewable', }, }, newValue: { - Type: 'UNKNOWN_RESOURCE_TYPE', + Type: 'AWS::SQS::Queue', Properties: { - QueueName: 'bye', + QueueName: 'value_after_change_is_not_viewable', + DelaySeconds: 'value_after_change_is_not_viewable', }, }, resourceTypes: { - oldType: 'UNKNOWN_RESOURCE_TYPE', - newType: 'UNKNOWN_RESOURCE_TYPE', + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', }, propertyDiffs: { QueueName: { - oldValue: 'hi', - newValue: 'bye', + oldValue: 'value_before_change_is_not_viewable', + newValue: 'value_after_change_is_not_viewable', isDifferent: true, - changeImpact: 'NO_CHANGE', + changeImpact: 'WILL_REPLACE', + }, + DelaySeconds: { + oldValue: 'value_before_change_is_not_viewable', + newValue: 'value_after_change_is_not_viewable', + isDifferent: true, + changeImpact: 'WILL_UPDATE', }, }, otherDiffs: { Type: { - oldValue: 'UNKNOWN_RESOURCE_TYPE', - newValue: 'UNKNOWN_RESOURCE_TYPE', + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', isDifferent: false, }, }, @@ -1827,4 +1835,121 @@ describe('method tests', () => { }); + // TODO -- delete thid describe block once IncludeInputValues is supported in all regions. + describe('convertContextlessChangeSetResourceToResource', () => { + + test('If resource exists and is missing Properties field, then Properties field is added', async () => { + // GIVEN + const propertiesThatChanged = ['QueueName', 'DelaySeconds']; + const resource: Resource = { + Type: 'AWS::SQS::Queue', + }; + + //WHEN + const beforeChangesResult = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( + 'AWS::SQS::Queue', + resource, + { + propertiesThatChanged, + beforeOrAfterChanges: 'BEFORE', + }, + ); + + // THEN + expect(beforeChangesResult).toEqual({ + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'value_before_change_is_not_viewable', + DelaySeconds: 'value_before_change_is_not_viewable', + }, + }); + }); + + test('properties that are missing from the diff are added', async () => { + // GIVEN + const QueueName = 'sillyQueue'; + const propertiesThatChanged = ['QueueName', 'DelaySeconds']; + const oldValueFromTemplateDiff : Resource = { + Type: 'AWS::SQS::Queue', + Properties: { QueueName }, + }; + const newValueFromTemplateDiff : Resource = { + Type: 'AWS::SQS::Queue', + Properties: { QueueName }, + }; + + //WHEN + const beforeChangesResult = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( + 'AWS::SQS::Queue', + oldValueFromTemplateDiff, + { + propertiesThatChanged, + beforeOrAfterChanges: 'BEFORE', + }, + ); + const afterChangesResult = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( + 'AWS::SQS::Queue', + newValueFromTemplateDiff, + { + propertiesThatChanged, + beforeOrAfterChanges: 'AFTER', + }, + ); + + // THEN + expect(afterChangesResult).toEqual({ + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: QueueName, + DelaySeconds: 'value_after_change_is_not_viewable', + }, + }); + expect(beforeChangesResult).toEqual({ + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: QueueName, + DelaySeconds: 'value_before_change_is_not_viewable', + }, + }); + }); + + test('returns propsWithBackupMessage if the resource does not exist in template diff', async () => { + //WHEN + const oldOrNewValueFromTemplateDiff = undefined; + const beforeChangesResult = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( + 'AWS::SQS::Queue', + oldOrNewValueFromTemplateDiff, + { + propertiesThatChanged: ['QueueName', 'DelaySeconds'], + beforeOrAfterChanges: 'BEFORE', + }, + ); + const afterChangesResult = TemplateAndChangeSetDiffMerger.convertContextlessChangeSetResourceToResource( + 'AWS::SQS::Queue', + oldOrNewValueFromTemplateDiff, + { + propertiesThatChanged: ['QueueName', 'DelaySeconds'], + beforeOrAfterChanges: 'AFTER', + }, + ); + + // THEN + expect(afterChangesResult).toEqual({ + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'value_after_change_is_not_viewable', + DelaySeconds: 'value_after_change_is_not_viewable', + }, + }); + expect(beforeChangesResult).toEqual({ + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'value_before_change_is_not_viewable', + DelaySeconds: 'value_before_change_is_not_viewable', + }, + }); + }); + + }); + }); From 357ff5e72014ceb6027ec46f02e180c3c6592a1e Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Thu, 23 May 2024 21:41:31 -0400 Subject: [PATCH 35/36] ready --- packages/aws-cdk/test/diff.test.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/aws-cdk/test/diff.test.ts b/packages/aws-cdk/test/diff.test.ts index fb9695e37857c..472949e8f6630 100644 --- a/packages/aws-cdk/test/diff.test.ts +++ b/packages/aws-cdk/test/diff.test.ts @@ -215,18 +215,9 @@ Resources expect(plainTextOutput).toContain(`Stack A Parameters and rules created during migration do not affect resource configuration. Resources -[←] UNKNOWN_RESOURCE_TYPE Queue import - └─ [~] RandomPropertyField - ├─ [-] value_before_change_is_not_viewable - └─ [+] value_after_change_is_not_viewable -[←] UNKNOWN_RESOURCE_TYPE Queue2 import - └─ [~] RandomPropertyField - ├─ [-] value_before_change_is_not_viewable - └─ [+] value_after_change_is_not_viewable -[←] UNKNOWN_RESOURCE_TYPE Bucket import - └─ [~] RandomPropertyField - ├─ [-] value_before_change_is_not_viewable - └─ [+] value_after_change_is_not_viewable +[←] AWS::SQS::Queue Queue import +[←] AWS::SQS::Queue Queue2 import +[←] AWS::S3::Bucket Bucket import `); expect(buffer.data.trim()).toContain('✨ Number of stacks with differences: 1'); From 3694e714cae3d2e4fad817e970ea312864c033b6 Mon Sep 17 00:00:00 2001 From: Jakob Berg Date: Thu, 23 May 2024 23:26:40 -0400 Subject: [PATCH 36/36] ready --- .../cloudformation-diff/lib/diff-template.ts | 2 +- .../template-and-changeset-diff-merger.ts | 26 ++++-- .../test/diff-template.test.ts | 8 -- ...template-and-changeset-diff-merger.test.ts | 81 ++++++++++++++++++- 4 files changed, 99 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts index d8982de5e848a..de4d44eb24b2c 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff-template.ts @@ -58,7 +58,7 @@ export function fullDiff( if (changeSet) { // These methods mutate the state of theDiff, using the changeSet. const changeSetDiff = new TemplateAndChangeSetDiffMerger({ changeSet }); - changeSetDiff.overrideDiffResourcesWithChangeSetResources(theDiff.resources); + changeSetDiff.overrideDiffResourcesWithChangeSetResources(theDiff.resources, currentTemplate.Resources); theDiff.resources.forEachDifference((logicalId: string, change: types.ResourceDifference) => changeSetDiff.overrideDiffResourceChangeImpactWithChangeSetChangeImpact(logicalId, change), ); diff --git a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts index d4e283c14a6f3..cd70b2aea0909 100644 --- a/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts +++ b/packages/@aws-cdk/cloudformation-diff/lib/diff/template-and-changeset-diff-merger.ts @@ -16,6 +16,9 @@ export class TemplateAndChangeSetDiffMerger { /** * TODO: Once IncludePropertyValues is supported in all regions, this function can be deleted + * + * @param args.currentTemplateResource resources and their properties only exist in the templateDiff if they are DIFFERENT. Therefore, + * in the case that a resource is in the changeset, but not in the templateDiff, we should still present the before changes value. Hence, this argument. */ public static convertContextlessChangeSetResourceToResource( changeSetResourceResourceType: string | undefined, @@ -23,9 +26,9 @@ export class TemplateAndChangeSetDiffMerger { args: { propertiesThatChanged: string[]; beforeOrAfterChanges: 'BEFORE' | 'AFTER'; + currentTemplateResource?: types.Resource; }, ): types.Resource { - const backupMessage = args.beforeOrAfterChanges === 'AFTER' ? 'value_after_change_is_not_viewable' : 'value_before_change_is_not_viewable'; const resourceExistsInTemplateDiff = oldOrNewValueFromTemplateDiff !== undefined; if (resourceExistsInTemplateDiff) { // if resourceExistsInTemplateDiff, then we don't want to erase the details of property changes that are in the template diff -- but we want @@ -39,15 +42,24 @@ export class TemplateAndChangeSetDiffMerger { // write properties from changeset that are missing from the template diff for (const propertyName of args.propertiesThatChanged) { - if (!(propertyName in oldOrNewValueFromTemplateDiff.Properties)) { - oldOrNewValueFromTemplateDiff.Properties[propertyName] = backupMessage; + if (!(propertyName in oldOrNewValueFromTemplateDiff.Properties)) { // I am not actually sure if this can happen... but it's better to be safe. It seems that if the resource exists in the templateDiff, then so do all of its Properties. + const propertyBeforeOrAfter = args.beforeOrAfterChanges === 'AFTER' + ? 'value_after_change_is_not_viewable' + : args.currentTemplateResource?.Properties?.[propertyName] ?? 'value_before_change_is_not_viewable'; + oldOrNewValueFromTemplateDiff.Properties[propertyName] = propertyBeforeOrAfter; } } + return oldOrNewValueFromTemplateDiff; } else { // The resource didn't change in the templateDiff but is mentioned in the changeset. E.g., perhaps because an ssm parameter, that defined a property, changed value. const propsWithBackUpMessage: { [propertyName: string]: string } = {}; - for (const propName of args.propertiesThatChanged) { propsWithBackUpMessage[propName] = backupMessage; } + for (const propertyName of args.propertiesThatChanged) { + const propertyBeforeOrAfter = args.beforeOrAfterChanges === 'AFTER' + ? 'value_after_change_is_not_viewable' + : args.currentTemplateResource?.Properties?.[propertyName] ?? 'value_before_change_is_not_viewable'; + propsWithBackUpMessage[propertyName] = propertyBeforeOrAfter; + } return { Type: changeSetResourceResourceType ?? TemplateAndChangeSetDiffMerger.UNKNOWN_RESOURCE_TYPE, Properties: propsWithBackUpMessage, @@ -137,7 +149,10 @@ export class TemplateAndChangeSetDiffMerger { * Overwrites the resource diff that was computed between the new and old template with the diff of the resources from the ChangeSet. * This is a more accurate way of computing the resource differences, since now cdk diff is reporting directly what the ChangeSet will apply. */ - public overrideDiffResourcesWithChangeSetResources(resourceDiffs: types.DifferenceCollection) { + public overrideDiffResourcesWithChangeSetResources( + resourceDiffs: types.DifferenceCollection, + currentTemplateResources: { [key: string]: any } | undefined, + ) { for (const [logicalIdFromChangeSet, changeSetResource] of Object.entries(this.changeSetResources)) { let oldResource: types.Resource; const changeSetIncludedBeforeContext = changeSetResource.beforeContext !== undefined; @@ -154,6 +169,7 @@ export class TemplateAndChangeSetDiffMerger { { propertiesThatChanged: Object.keys(changeSetResource.propertyReplacementModes || {}), beforeOrAfterChanges: 'BEFORE', + currentTemplateResource: currentTemplateResources?.[logicalIdFromChangeSet], }, ); } diff --git a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts index cfc505b16c346..78c68d474f5b8 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/diff-template.test.ts @@ -11,14 +11,6 @@ const BUCKET_POLICY_RESOURCE = { }, }; -describe('diffResource', () => { - - test('properties that are not different are not added to the diffs', () => { - - }); - -}); - test('when there is no difference', () => { const bucketName = 'ShineyBucketName'; const currentTemplate = { diff --git a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts index 3c5be80481023..aedcde2379156 100644 --- a/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts +++ b/packages/@aws-cdk/cloudformation-diff/test/template-and-changeset-diff-merger.test.ts @@ -1006,6 +1006,79 @@ describe('fullDiff tests that include changeset', () => { }); + test('works with values defined before but not after (coming from changeset)', async () => { + // GIVEN + const currentTemplate = { + Resources: { + Queue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: 'nice', + DelaySeconds: '10', + }, + }, + }, + }; + + const changeSet = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + LogicalResourceId: 'Queue', + ResourceType: 'AWS::SQS::Queue', + Replacement: 'False', + Scope: ['Properties'], + Details: [ + { Target: { Attribute: 'Properties', Name: 'DelaySeconds', RequiresRecreation: 'Never' } }, + ], + }, + }, + ], + }; + + // WHEN + const diff = fullDiff(currentTemplate, currentTemplate, changeSet as any); + expect(diff.resources.changes.Queue).toEqual({ + oldValue: { + Type: 'AWS::SQS::Queue', + Properties: { + DelaySeconds: '10', + }, + }, + newValue: { + Type: 'AWS::SQS::Queue', + Properties: { + DelaySeconds: 'value_after_change_is_not_viewable', + }, + }, + resourceTypes: { + oldType: 'AWS::SQS::Queue', + newType: 'AWS::SQS::Queue', + }, + propertyDiffs: { + DelaySeconds: { + oldValue: '10', + newValue: 'value_after_change_is_not_viewable', + isDifferent: true, + changeImpact: 'WILL_UPDATE', + }, + }, + otherDiffs: { + Type: { + oldValue: 'AWS::SQS::Queue', + newValue: 'AWS::SQS::Queue', + isDifferent: false, + }, + }, + isAddition: false, + isRemoval: false, + isImport: undefined, + }); + + }); + }); describe('method tests', () => { @@ -1268,7 +1341,7 @@ describe('method tests', () => { const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSet }); //WHEN - templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources, {}); // THEN expect(resources.differenceCount).toBe(2); @@ -1423,7 +1496,7 @@ describe('method tests', () => { const templateAndChangeSetDiffMerger = new TemplateAndChangeSetDiffMerger({ changeSet: utils.changeSetWithMissingChanges }); //WHEN - templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources, {}); // THEN expect(resources.differenceCount).toBe(0); @@ -1448,7 +1521,7 @@ describe('method tests', () => { }); //WHEN - templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources, {}); // THEN expect(resources.differenceCount).toBe(1); @@ -1511,7 +1584,7 @@ describe('method tests', () => { }); //WHEN - templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources); + templateAndChangeSetDiffMerger.overrideDiffResourcesWithChangeSetResources(resources, {}); // THEN expect(resources.differenceCount).toBe(1);