From 21b3b82d1e263906bc40d40d8f68efcee3ddd7fd Mon Sep 17 00:00:00 2001 From: Otavio Macedo <288203+otaviomacedo@users.noreply.github.com> Date: Wed, 28 May 2025 14:00:47 +0100 Subject: [PATCH] fix: stack definitions for refactor are not considering resource references The `createStackRefactor` method, from the CloudFormation API, requires the client to pass not only the mappings, but also the templates in the state they are supposed to be after the refactor. Previously, this was done by iterating over the mappings (either computed by the toolkit or prescribed by the user), and using them as a list of instructions on how to modify the deployed stacks, moving resources around as necessary. This is a simple way to put the resources in their correct locations. But it becomes unwieldy when it comes to updating references between resources. Every time a resource is moved, all references to it -- and sometimes from it -- have to be updated as well. This PR changes implements a different algorithm: take all the synthesized templates as a starting point, and adjust them so that the final templates are equal to the deployed ones up to logical IDs and references. The result is what will be sent to CloudFormation. Specifically this involves: - Removing resources that exist locally but not remotely. - Adding resources that exist remotely but not locally. - Update the CDK construct path to the deployed value. (we will drop this once CloudFormation starts allowing changes in the construct path along with refactors). One case that is not yet covered is when physical IDs are set. Because we use the physical ID plus the type of the resource to identify it, users are free to change other properties, and we can still recognize that it's the same resource. Since we are simply taking the local resource as the basis and not updating anything other than the construct path, it's possible that it differs from the deployed one. This case will be handled in a following PR. --- .../lib/api/refactoring/execution.ts | 115 +- .../test/api/refactoring/refactoring.test.ts | 1217 ++++++++++++++++- 2 files changed, 1257 insertions(+), 75 deletions(-) diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts index e9b838f79..54b32ddc0 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/refactoring/execution.ts @@ -1,53 +1,98 @@ import type { StackDefinition } from '@aws-sdk/client-cloudformation'; -import type { CloudFormationStack, ResourceMapping } from './cloudformation'; +import type { + CloudFormationResource, + CloudFormationStack, + CloudFormationTemplate, + ResourceMapping, +} from './cloudformation'; import { ToolkitError } from '../../toolkit/toolkit-error'; /** * Generates a list of stack definitions to be sent to the CloudFormation API * by applying each mapping to the corresponding stack template(s). */ -export function generateStackDefinitions(mappings: ResourceMapping[], deployedStacks: CloudFormationStack[]): StackDefinition[] { - const templates = Object.fromEntries( - deployedStacks - .filter((s) => - mappings.some( - (m) => - // We only care about stacks that are part of the mappings - m.source.stack.stackName === s.stackName || m.destination.stack.stackName === s.stackName, - ), - ) - .map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template))]), +export function generateStackDefinitions( + mappings: ResourceMapping[], + deployedStacks: CloudFormationStack[], + localStacks: CloudFormationStack[], +): StackDefinition[] { + const localTemplates = Object.fromEntries( + localStacks.map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template)) as CloudFormationTemplate]), ); + const deployedTemplates = Object.fromEntries( + deployedStacks.map((s) => [s.stackName, JSON.parse(JSON.stringify(s.template)) as CloudFormationTemplate]), + ); + + // First, remove from the local templates any resources that are not in the deployed templates + iterate(localTemplates, (stackName, logicalResourceId) => { + const location = searchLocation(stackName, logicalResourceId, 'destination', 'source'); + + const deployedResource = deployedStacks.find((s) => s.stackName === location.stackName)?.template + .Resources?.[location.logicalResourceId]; - mappings.forEach((mapping) => { - const sourceStackName = mapping.source.stack.stackName; - const sourceLogicalId = mapping.source.logicalResourceId; - const sourceTemplate = templates[sourceStackName]; - - const destinationStackName = mapping.destination.stack.stackName; - const destinationLogicalId = mapping.destination.logicalResourceId; - if (templates[destinationStackName] == null) { - // The API doesn't allow anything in the template other than the resources - // that are part of the mappings. So we need to create an empty template - // to start adding resources to. - templates[destinationStackName] = { Resources: {} }; + if (deployedResource == null) { + delete localTemplates[stackName].Resources?.[logicalResourceId]; } - const destinationTemplate = templates[destinationStackName]; + }); + + // Now do the opposite: add to the local templates any resources that are in the deployed templates + iterate(deployedTemplates, (stackName, logicalResourceId, deployedResource) => { + const location = searchLocation(stackName, logicalResourceId, 'source', 'destination'); + + const resources = Object + .entries(localTemplates) + .find(([name, _]) => name === location.stackName)?.[1].Resources; + const localResource = resources?.[location.logicalResourceId]; - // Do the move - destinationTemplate.Resources[destinationLogicalId] = sourceTemplate.Resources[sourceLogicalId]; - delete sourceTemplate.Resources[sourceLogicalId]; + if (localResource == null) { + if (localTemplates[stackName]?.Resources) { + localTemplates[stackName].Resources[logicalResourceId] = deployedResource; + } + } else { + // This is temporary, until CloudFormation supports CDK construct path updates in the refactor API + if (localResource.Metadata != null) { + localResource.Metadata['aws:cdk:path'] = deployedResource.Metadata?.['aws:cdk:path']; + } + } }); - // CloudFormation doesn't allow empty stacks - for (const [stackName, template] of Object.entries(templates)) { + function searchLocation(stackName: string, logicalResourceId: string, from: 'source' | 'destination', to: 'source' | 'destination') { + const mapping = mappings.find( + (m) => m[from].stack.stackName === stackName && m[from].logicalResourceId === logicalResourceId, + ); + return mapping != null + ? { stackName: mapping[to].stack.stackName, logicalResourceId: mapping[to].logicalResourceId } + : { stackName, logicalResourceId }; + } + + function iterate( + templates: Record, + cb: (stackName: string, logicalResourceId: string, resource: CloudFormationResource) => void, + ) { + Object.entries(templates).forEach(([stackName, template]) => { + Object.entries(template.Resources ?? {}).forEach(([logicalResourceId, resource]) => { + cb(stackName, logicalResourceId, resource); + }); + }); + } + + for (const [stackName, template] of Object.entries(localTemplates)) { if (Object.keys(template.Resources ?? {}).length === 0) { - throw new ToolkitError(`Stack ${stackName} has no resources after refactor. You must add a resource to this stack. This resource can be a simple one, like a waitCondition resource type.`); + throw new ToolkitError( + `Stack ${stackName} has no resources after refactor. You must add a resource to this stack. This resource can be a simple one, like a waitCondition resource type.`, + ); } } - return Object.entries(templates).map(([stackName, template]) => ({ - StackName: stackName, - TemplateBody: JSON.stringify(template), - })); + return Object.entries(localTemplates) + .filter(([stackName, _]) => + mappings.some((m) => { + // Only send templates for stacks that are part of the mappings + return m.source.stack.stackName === stackName || m.destination.stack.stackName === stackName; + }), + ) + .map(([stackName, template]) => ({ + StackName: stackName, + TemplateBody: JSON.stringify(template), + })); } diff --git a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts index b8127c610..4ebd946bf 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/refactoring/refactoring.test.ts @@ -1835,7 +1835,7 @@ describe(generateStackDefinitions, () => { }; test('renames a resource within the same stack', () => { - const stack: CloudFormationStack = { + const stack1: CloudFormationStack = { environment: environment, stackName: 'Foo', template: { @@ -1846,27 +1846,61 @@ describe(generateStackDefinitions, () => { NotInvolved: { Type: 'AWS::X::Y', }, + Consumer: { + Type: 'AWS::X::Y', + Properties: { + Bucket: { Ref: 'Bucket1' }, + }, + }, + }, + }, + }; + + const stack2: CloudFormationStack = { + environment: environment, + stackName: 'Foo', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + NotInvolved: { + Type: 'AWS::X::Y', + }, + Consumer: { + Type: 'AWS::X::Y', + Properties: { + Bucket: { Ref: 'Bucket2' }, + }, + }, }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack, 'Bucket1'), new ResourceLocation(stack, 'Bucket2')), + new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack1, 'Bucket2')), ]; - const result = generateStackDefinitions(mappings, [stack]); + const result = generateStackDefinitions(mappings, [stack1], [stack2]); expect(result).toEqual([ { StackName: 'Foo', TemplateBody: JSON.stringify({ Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, // Not involved in the refactor, but still part of the // original template. Should be included. NotInvolved: { Type: 'AWS::X::Y', }, - Bucket2: { - Type: 'AWS::S3::Bucket', + Consumer: { + Type: 'AWS::X::Y', + Properties: { + // The reference has also been updated + Bucket: { Ref: 'Bucket2' }, + }, }, }, }), @@ -1875,7 +1909,7 @@ describe(generateStackDefinitions, () => { }); test('moves a resource to another stack that has already been deployed', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -1890,7 +1924,30 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + B: { + Type: 'AWS::B::B', + }, + }, + }, + }; + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -1898,15 +1955,25 @@ describe(generateStackDefinitions, () => { B: { Type: 'AWS::B::B', }, + Bucket2: { + Type: 'AWS::S3::Bucket', + }, }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack2, 'Bucket2'), + ), ]; - const result = generateStackDefinitions(mappings, [stack1, stack2]); + const result = generateStackDefinitions( + mappings, + [deployedStack1, deployedStack2], + [localStack1, localStack2], + ); expect(result).toEqual([ { StackName: 'Stack1', @@ -1941,7 +2008,7 @@ describe(generateStackDefinitions, () => { }); test('moves a resource to another stack that has not been deployed', () => { - const stack1: CloudFormationStack = { + const deployedStack: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -1956,45 +2023,57 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const localStack1: CloudFormationStack = { environment, stackName: 'Stack2', template: { Resources: { - B: { - Type: 'AWS::B::B', + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + A: { + Type: 'AWS::A::A', }, }, }, }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')), + new ResourceMapping(new ResourceLocation(deployedStack, 'Bucket1'), new ResourceLocation(localStack1, 'Bucket2')), ]; - const result = generateStackDefinitions(mappings, [stack1]); + const result = generateStackDefinitions(mappings, [deployedStack], [localStack1, localStack2]); expect(result).toEqual([ { - StackName: 'Stack1', + StackName: 'Stack2', TemplateBody: JSON.stringify({ Resources: { - // Wasn't touched by the refactor - A: { - Type: 'AWS::A::A', + // Old Bucket1 is now Bucket2 here + Bucket2: { + Type: 'AWS::S3::Bucket', }, - - // Bucket1 doesn't exist anymore }, }), }, { - StackName: 'Stack2', + StackName: 'Stack1', TemplateBody: JSON.stringify({ Resources: { - // Old Bucket1 is now Bucket2 here - Bucket2: { - Type: 'AWS::S3::Bucket', + // Wasn't touched by the refactor + A: { + Type: 'AWS::A::A', }, + + // Bucket1 doesn't exist anymore }, }), }, @@ -2002,7 +2081,7 @@ describe(generateStackDefinitions, () => { }); test('multiple mappings', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -2017,7 +2096,7 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -2029,13 +2108,53 @@ describe(generateStackDefinitions, () => { }, }; + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket6: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + Bucket4: { + Type: 'AWS::S3::Bucket', + }, + Bucket5: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket4')), - new ResourceMapping(new ResourceLocation(stack1, 'Bucket2'), new ResourceLocation(stack2, 'Bucket5')), - new ResourceMapping(new ResourceLocation(stack2, 'Bucket3'), new ResourceLocation(stack1, 'Bucket6')), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack2, 'Bucket4'), + ), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket2'), + new ResourceLocation(deployedStack2, 'Bucket5'), + ), + new ResourceMapping( + new ResourceLocation(deployedStack2, 'Bucket3'), + new ResourceLocation(deployedStack1, 'Bucket6'), + ), ]; - const result = generateStackDefinitions(mappings, [stack1, stack2]); + const result = generateStackDefinitions( + mappings, + [deployedStack1, deployedStack2], + [localStack1, localStack2], + ); expect(result).toEqual([ { StackName: 'Stack1', @@ -2064,7 +2183,7 @@ describe(generateStackDefinitions, () => { }); test('deployed stacks that are not in any mapping', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -2076,7 +2195,31 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket3: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -2089,10 +2232,17 @@ describe(generateStackDefinitions, () => { }; const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack1, 'Bucket3')), + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack1, 'Bucket3'), + ), ]; - const result = generateStackDefinitions(mappings, [stack1, stack2]); + const result = generateStackDefinitions( + mappings, + [deployedStack1, deployedStack2], + [localStack1, localStack2], + ); expect(result).toEqual([ { StackName: 'Stack1', @@ -2108,7 +2258,7 @@ describe(generateStackDefinitions, () => { }); test('refactor should not create empty templates', () => { - const stack1: CloudFormationStack = { + const deployedStack1: CloudFormationStack = { environment, stackName: 'Stack1', template: { @@ -2120,7 +2270,7 @@ describe(generateStackDefinitions, () => { }, }; - const stack2: CloudFormationStack = { + const deployedStack2: CloudFormationStack = { environment, stackName: 'Stack2', template: { @@ -2128,12 +2278,999 @@ describe(generateStackDefinitions, () => { }, }; + const localStack1: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: {}, + }, + }; + + const localStack2: CloudFormationStack = { + environment, + stackName: 'Stack2', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStack1, 'Bucket1'), + new ResourceLocation(deployedStack2, 'Bucket2'), + ), + ]; + + expect(() => + generateStackDefinitions(mappings, [deployedStack1, deployedStack2], [localStack1, localStack2]), + ).toThrow(/Stack Stack1 has no resources after refactor/); + }); + + test('local stacks have more resources than deployed stacks', async () => { + const deployedStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + + const localStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + ExtraStuff: { + Type: 'AWS::X::Y', + }, + }, + }, + }; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStack, 'Bucket1'), + new ResourceLocation(deployedStack, 'Bucket2'), + ), + ]; + + const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + expect(result).toEqual([ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + // ExtraStuff is not involved in the refactor and was not part of the deployed stack, so we keep it out. + }, + }), + }, + ]); + }); + + test('local stacks have fewer resources than deployed stacks', () => { + const deployedStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + }, + ExtraStuff: { + Type: 'AWS::X::Y', + }, + }, + }, + }; + + const localStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + }, + }, + }; + const mappings: ResourceMapping[] = [ - new ResourceMapping(new ResourceLocation(stack1, 'Bucket1'), new ResourceLocation(stack2, 'Bucket2')), + new ResourceMapping( + new ResourceLocation(deployedStack, 'Bucket1'), + new ResourceLocation(deployedStack, 'Bucket2'), + ), ]; - expect(() => generateStackDefinitions(mappings, [stack1, stack2])) - .toThrow(/Stack Stack1 has no resources after refactor/); + const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + expect(result).toEqual([ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + }, + // ExtraStuff is not involved in the refactor, but it was part of the deployed stack, so we keep it in. + ExtraStuff: { + Type: 'AWS::X::Y', + }, + }, + }), + }, + ]); + }); + + test('CDK path in Metadata is preserved', () => { + const deployedStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket1: { + Type: 'AWS::S3::Bucket', + Metadata: { + 'aws:cdk:path': 'Stack1/Bucket1/Resource', + }, + }, + }, + }, + }; + + const localStack: CloudFormationStack = { + environment, + stackName: 'Stack1', + template: { + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + Metadata: { + // Here the CDK path is consistent with the new logical ID... + 'aws:cdk:path': 'Stack1/Bucket2/Resource', + }, + }, + }, + }, + }; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStack, 'Bucket1'), + new ResourceLocation(deployedStack, 'Bucket2'), + ), + ]; + + const result = generateStackDefinitions(mappings, [deployedStack], [localStack]); + expect(result).toEqual([ + { + StackName: 'Stack1', + TemplateBody: JSON.stringify({ + Resources: { + Bucket2: { + Type: 'AWS::S3::Bucket', + Metadata: { + // ...but we keep the original CDK path from the deployed stack, to make CloudFormation happy. + 'aws:cdk:path': 'Stack1/Bucket1/Resource', + }, + }, + }, + }), + }, + ]); + }); + + describe('With references between resources', () => { + describe('Divergence - reference starts within the same stack and, in some cases, crosses stacks', () => { + test('No stack move', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'Bn' }, + }, + }, + Bn: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[0], 'Bn')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'Bn' }, // Updated reference + }, + }, + Bn: { + Type: 'AWS::B::B', + }, + }, + }), + }, + ]); + }); + + test('tail of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackX', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the moved resource + }, + }, + }, + }), + }, + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, // The moved resource + }, + }), + }, + ]); + }); + + test('head of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[1], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + // A was moved + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, // Reference to the resource that stayed behind + }, + }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('both moved to the same stack', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[1], 'B')), + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[1], 'A')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('both moved to different stacks', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { + Type: 'AWS::B::B', + }, + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackZ', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { + Type: 'AWS::B::B', + }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[1], 'A')), + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'B'), new ResourceLocation(localStacks[2], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + C: { + Type: 'AWS::C::C', + }, + }, + }), + }, + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }), + }, + { + StackName: 'StackZ', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { + Type: 'AWS::B::B', + }, + }, + }), + }, + ]); + }); + }); + + describe('Convergence - reference starts cross-stack and, in some cases, moves to within the same stack', () => { + test('No stack move', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BnFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'Bn' }, + Export: { + Name: 'BnFromOtherStack', + }, + }, + }, + Resources: { + Bn: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping( + new ResourceLocation(deployedStacks[1], 'B'), + new ResourceLocation(deployedStacks[1], 'Bn'), + ), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + // StackX was not part of the mappings + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Outputs: { + Bout: { + Value: { Ref: 'Bn' }, + Export: { + Name: 'BnFromOtherStack', + }, + }, + }, + Resources: { + Bn: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('tail of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackY', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackY', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + // The reference has been updated as the resource was moved + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('head of the reference moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackX', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + // The reference has been updated to the moved resource + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + + test('both moved', () => { + const deployedStacks: CloudFormationStack[] = [ + { + environment, + stackName: 'StackX', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { 'Fn::ImportValue': 'BFromOtherStack' }, + }, + }, + }, + }, + }, + { + environment, + stackName: 'StackY', + template: { + Outputs: { + Bout: { + Value: { Ref: 'B' }, + Export: { + Name: 'BFromOtherStack', + }, + }, + }, + Resources: { + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const localStacks: CloudFormationStack[] = [ + { + environment, + // This is a third stack that will receive both resources + stackName: 'StackZ', + template: { + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }, + }, + ]; + + const mappings: ResourceMapping[] = [ + new ResourceMapping(new ResourceLocation(deployedStacks[0], 'A'), new ResourceLocation(localStacks[0], 'A')), + new ResourceMapping(new ResourceLocation(deployedStacks[1], 'B'), new ResourceLocation(localStacks[0], 'B')), + ]; + + const result = generateStackDefinitions(mappings, deployedStacks, localStacks); + expect(result).toEqual([ + { + StackName: 'StackZ', + TemplateBody: JSON.stringify({ + Resources: { + A: { + Type: 'AWS::A::A', + Properties: { + Props: { Ref: 'B' }, + }, + }, + B: { Type: 'AWS::B::B' }, + }, + }), + }, + ]); + }); + }); }); });