diff --git a/packages/amplify-gen2-codegen/API.md b/packages/amplify-gen2-codegen/API.md index 1a9685ccdf1..0f140f2881d 100644 --- a/packages/amplify-gen2-codegen/API.md +++ b/packages/amplify-gen2-codegen/API.md @@ -125,7 +125,7 @@ export interface Gen2RenderingOptions { // (undocumented) backendEnvironmentName?: string | undefined; // (undocumented) - customResources?: string[]; + customResources?: Map; // (undocumented) data?: DataDefinition; // (undocumented) diff --git a/packages/amplify-gen2-codegen/src/backend/synthesizer.test.ts b/packages/amplify-gen2-codegen/src/backend/synthesizer.test.ts index af4a3150ee1..e3ca453a732 100644 --- a/packages/amplify-gen2-codegen/src/backend/synthesizer.test.ts +++ b/packages/amplify-gen2-codegen/src/backend/synthesizer.test.ts @@ -521,7 +521,10 @@ describe('BackendRenderer', () => { it('renders custom resources', () => { const renderer = new BackendSynthesizer(); const rendered = renderer.render({ - customResources: ['resource1', 'resource2'], + customResources: new Map([ + ['resource1', 'resource1Value'], + ['resource2', 'resource2Value'], + ]), }); const output = printNodeArray(rendered); diff --git a/packages/amplify-gen2-codegen/src/backend/synthesizer.ts b/packages/amplify-gen2-codegen/src/backend/synthesizer.ts index c48cab50f4f..b4a260ded86 100644 --- a/packages/amplify-gen2-codegen/src/backend/synthesizer.ts +++ b/packages/amplify-gen2-codegen/src/backend/synthesizer.ts @@ -46,7 +46,7 @@ export interface BackendRenderParameters { importFrom: string; functionNamesAndCategories: Map; }; - customResources?: string[]; + customResources?: Map; unsupportedCategories?: Map; } @@ -697,7 +697,7 @@ export class BackendSynthesizer { TemporaryPasswordValidityDays: 'temporaryPasswordValidityDays', }; - if (renderArgs.auth || renderArgs.storage?.hasS3Bucket) { + if (renderArgs.auth || renderArgs.storage?.hasS3Bucket || renderArgs.customResources) { imports.push( this.createImportStatement([factory.createIdentifier('RemovalPolicy'), factory.createIdentifier('Tags')], 'aws-cdk-lib'), ); @@ -757,30 +757,30 @@ export class BackendSynthesizer { } if (renderArgs.customResources) { - for (const resource of renderArgs.customResources) { + for (const [resourceName, className] of renderArgs.customResources) { const importStatement = factory.createImportDeclaration( undefined, factory.createImportClause( false, undefined, factory.createNamedImports([ - factory.createImportSpecifier(false, factory.createIdentifier('cdkStack'), factory.createIdentifier(`${resource}`)), + factory.createImportSpecifier(false, factory.createIdentifier(`${className}`), factory.createIdentifier(`${resourceName}`)), ]), ), - factory.createStringLiteral(`./custom/${resource}/cdk-stack`), + factory.createStringLiteral(`./custom/${resourceName}/cdk-stack`), undefined, ); imports.push(importStatement); - const customResourceExpression = factory.createNewExpression(factory.createIdentifier(`${resource}`), undefined, [ + const customResourceExpression = factory.createNewExpression(factory.createIdentifier(`${resourceName}`), undefined, [ factory.createPropertyAccessExpression(factory.createIdentifier('backend'), factory.createIdentifier('stack')), - factory.createStringLiteral(`${resource}`), + factory.createStringLiteral(`${resourceName}`), factory.createIdentifier('undefined'), factory.createObjectLiteralExpression( [ factory.createPropertyAssignment(factory.createIdentifier('category'), factory.createStringLiteral('custom')), - factory.createPropertyAssignment(factory.createIdentifier('resourceName'), factory.createStringLiteral(`${resource}`)), + factory.createPropertyAssignment(factory.createIdentifier('resourceName'), factory.createStringLiteral(`${resourceName}`)), ], true, ), @@ -1043,7 +1043,7 @@ export class BackendSynthesizer { // Add a tag commented out to force a deployment post refactor // Tags.of(backend.stack).add('gen1-migrated-app', 'true') - if (renderArgs.auth || renderArgs.storage?.hasS3Bucket) { + if (renderArgs.auth || renderArgs.storage?.hasS3Bucket || renderArgs.customResources) { const tagAssignment = factory.createExpressionStatement( factory.createCallExpression( factory.createPropertyAccessExpression( diff --git a/packages/amplify-gen2-codegen/src/index.ts b/packages/amplify-gen2-codegen/src/index.ts index 61aedb7907e..a9acc94caf1 100644 --- a/packages/amplify-gen2-codegen/src/index.ts +++ b/packages/amplify-gen2-codegen/src/index.ts @@ -56,7 +56,7 @@ export interface Gen2RenderingOptions { storage?: StorageRenderParameters; data?: DataDefinition; functions?: FunctionDefinition[]; - customResources?: string[]; + customResources?: Map; unsupportedCategories?: Map; fileWriter?: (content: string, path: string) => Promise; } @@ -215,7 +215,7 @@ export const createGen2Renderer = ({ }; } - if (customResources && customResources.length > 0) { + if (customResources && customResources.size > 0) { backendRenderOptions.customResources = customResources; } diff --git a/packages/amplify-migration-template-gen/API.md b/packages/amplify-migration-template-gen/API.md index 11f96f796b4..30a9e3c5bbd 100644 --- a/packages/amplify-migration-template-gen/API.md +++ b/packages/amplify-migration-template-gen/API.md @@ -8,11 +8,21 @@ import { CloudFormationClient } from '@aws-sdk/client-cloudformation'; import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; import { SSMClient } from '@aws-sdk/client-ssm'; +// @public (undocumented) +export interface ResourceMapping { + // (undocumented) + Destination: ResourceMappingLocation; + // Warning: (ae-forgotten-export) The symbol "ResourceMappingLocation" needs to be exported by the entry point index.d.ts + // + // (undocumented) + Source: ResourceMappingLocation; +} + // @public (undocumented) export class TemplateGenerator { constructor(fromStack: string, toStack: string, accountId: string, cfnClient: CloudFormationClient, ssmClient: SSMClient, cognitoIdpClient: CognitoIdentityProviderClient, appId: string, environmentName: string); // (undocumented) - generate(): Promise; + generate(customResourceMap?: ResourceMapping[]): Promise; // (undocumented) revert(): Promise; } diff --git a/packages/amplify-migration-template-gen/src/category-template-generator.test.ts b/packages/amplify-migration-template-gen/src/category-template-generator.test.ts index 7ec31b11bda..87b54dd9e01 100644 --- a/packages/amplify-migration-template-gen/src/category-template-generator.test.ts +++ b/packages/amplify-migration-template-gen/src/category-template-generator.test.ts @@ -3,6 +3,7 @@ import { CFN_AUTH_TYPE, CFN_PSEUDO_PARAMETERS_REF, CFN_S3_TYPE, CFNTemplate } fr import { CloudFormationClient, DescribeStacksCommand, + DescribeStackResourcesCommand, DescribeStacksOutput, GetTemplateCommand, GetTemplateOutput, @@ -659,6 +660,20 @@ jest.mock('@aws-sdk/client-cloudformation', () => { return Promise.resolve(generateDescribeStacksResponse(command)); } else if (command instanceof GetTemplateCommand) { return Promise.resolve(generateGetTemplateResponse(command)); + } else if (command instanceof DescribeStackResourcesCommand) { + return Promise.resolve({ + StackResources: [ + { + StackId: command.input.StackName, + StackName: command.input.StackName, + LogicalResourceId: GEN1_S3_BUCKET_LOGICAL_ID, + PhysicalResourceId: GEN1_S3_BUCKET_LOGICAL_ID, + ResourceType: CFN_S3_TYPE.Bucket, + ResourceStatus: 'CREATE_COMPLETE', + Timestamp: new Date(), + }, + ], + }); } return Promise.resolve({}); }), @@ -845,6 +860,20 @@ describe('CategoryTemplateGenerator', () => { ? JSON.stringify(oldGen1Template) : JSON.stringify(oldGen2TemplateWithoutS3Bucket), }); + } else if (command instanceof DescribeStackResourcesCommand) { + return Promise.resolve({ + StackResources: [ + { + StackId: command.input.StackName, + StackName: command.input.StackName, + LogicalResourceId: GEN1_S3_BUCKET_LOGICAL_ID, + PhysicalResourceId: GEN1_S3_BUCKET_LOGICAL_ID, + ResourceType: CFN_S3_TYPE.Bucket, + ResourceStatus: 'CREATE_COMPLETE', + Timestamp: new Date(), + }, + ], + }); } return Promise.resolve({}); }; @@ -852,6 +881,7 @@ describe('CategoryTemplateGenerator', () => { .mockImplementationOnce(sendFailureMock) .mockImplementationOnce(sendFailureMock) .mockImplementationOnce(sendFailureMock) + .mockImplementationOnce(sendFailureMock) .mockImplementationOnce(sendFailureMock); await noGen1ResourcesToMoveS3TemplateGenerator.generateGen1PreProcessTemplate(); await expect(noGen1ResourcesToMoveS3TemplateGenerator.generateGen2ResourceRemovalTemplate()).rejects.toThrowError( diff --git a/packages/amplify-migration-template-gen/src/category-template-generator.ts b/packages/amplify-migration-template-gen/src/category-template-generator.ts index 0e6b4ce2bfc..884f8a246b1 100644 --- a/packages/amplify-migration-template-gen/src/category-template-generator.ts +++ b/packages/amplify-migration-template-gen/src/category-template-generator.ts @@ -1,4 +1,11 @@ -import { CloudFormationClient, DescribeStacksCommand, GetTemplateCommand, Stack } from '@aws-sdk/client-cloudformation'; +import { + CloudFormationClient, + DescribeStacksCommand, + DescribeStackResourcesCommand, + GetTemplateCommand, + Stack, + Parameter, +} from '@aws-sdk/client-cloudformation'; import { SSMClient } from '@aws-sdk/client-ssm'; import assert from 'node:assert'; import { @@ -32,8 +39,10 @@ const RESOURCE_TYPES_WITH_MULTIPLE_RESOURCES = [ class CategoryTemplateGenerator { private gen1DescribeStacksResponse: Stack | undefined; private gen2DescribeStacksResponse: Stack | undefined; - private gen1ResourcesToMove: Map; - private gen2ResourcesToRemove: Map; + public gen1ResourcesToMove: Map; + public gen2ResourcesToRemove: Map; + public gen2Template: CFNTemplate | undefined; + public gen2StackParameters: Parameter[] | undefined; constructor( private readonly gen1StackId: string, private readonly gen2StackId: string, @@ -72,9 +81,12 @@ class CategoryTemplateGenerator { const gen1ParametersResolvedTemplate = new CfnParameterResolver(oldGen1Template, extractStackNameFromId(this.gen1StackId)).resolve( Parameters, ); + + const stackResources = await this.describeStackResources(this.gen1StackId); const gen1TemplateWithOutputsResolved = new CfnOutputResolver(gen1ParametersResolvedTemplate, this.region, this.accountId).resolve( logicalResourceIds, Outputs, + stackResources, ); const gen1TemplateWithDepsResolved = new CfnDependencyResolver(gen1TemplateWithOutputsResolved).resolve(logicalResourceIds); const gen1TemplateWithConditionsResolved = new CFNConditionResolver(gen1TemplateWithDepsResolved).resolve(Parameters); @@ -106,16 +118,21 @@ class CategoryTemplateGenerator { assert(this.gen2DescribeStacksResponse); const { Parameters, Outputs } = this.gen2DescribeStacksResponse; assert(Outputs); + this.gen2StackParameters = Parameters; const oldGen2Template = await this.readTemplate(this.gen2StackId); + this.gen2Template = oldGen2Template; this.gen2ResourcesToRemove = new Map( - Object.entries(oldGen2Template.Resources).filter(([, value]) => - this.resourcesToMove.some((resourceToMove) => resourceToMove.valueOf() === value.Type), - ), + Object.entries(oldGen2Template.Resources).filter(([logicalId, value]) => { + return ( + this.resourcesToMovePredicate?.(this.resourcesToMove, [logicalId, value]) ?? + this.resourcesToMove.some((resourceToMove) => resourceToMove.valueOf() === value.Type) + ); + }), ); // validate empty resources if (this.gen2ResourcesToRemove.size === 0) throw new Error('No resources to remove in Gen2 stack.'); const logicalResourceIds = [...this.gen2ResourcesToRemove.keys()]; - const updatedGen2Template = this.removeGen2ResourcesFromGen2Stack(oldGen2Template, logicalResourceIds); + const updatedGen2Template = await this.removeGen2ResourcesFromGen2Stack(oldGen2Template, logicalResourceIds); return { oldTemplate: oldGen2Template, newTemplate: updatedGen2Template, @@ -148,6 +165,18 @@ class CategoryTemplateGenerator { ).Stacks?.[0]; } + private async describeStackResources(stackId: string) { + const { StackResources } = await this.cfnClient.send( + new DescribeStackResourcesCommand({ + StackName: stackId, + }), + ); + + assert(StackResources && StackResources.length > 0); + + return StackResources; + } + private removeGen1ResourcesFromGen1Stack(gen1Template: CFNTemplate, resourcesToRefactor: string[]) { const resources = gen1Template.Resources; assert(resources); @@ -217,13 +246,16 @@ class CategoryTemplateGenerator { return gen1ToGen2ResourceLogicalIdMapping; } - private removeGen2ResourcesFromGen2Stack(gen2Template: CFNTemplate, resourcesToRemove: string[]) { + private async removeGen2ResourcesFromGen2Stack(gen2Template: CFNTemplate, resourcesToRemove: string[]) { const clonedGen2Template = JSON.parse(JSON.stringify(gen2Template)); const stackOutputs = this.gen2DescribeStacksResponse?.Outputs; assert(stackOutputs); - const resolvedRefsGen2Template = new CfnOutputResolver(clonedGen2Template, this.region, this.accountId).resolve( + const stackResources = await this.describeStackResources(this.gen2StackId); + const gen2TemplateWithDepsResolved = new CfnDependencyResolver(clonedGen2Template).resolve(resourcesToRemove); + const resolvedRefsGen2Template = new CfnOutputResolver(gen2TemplateWithDepsResolved, this.region, this.accountId).resolve( resourcesToRemove, stackOutputs, + stackResources, ); resourcesToRemove.forEach((logicalResourceId) => { delete resolvedRefsGen2Template.Resources[logicalResourceId]; diff --git a/packages/amplify-migration-template-gen/src/index.ts b/packages/amplify-migration-template-gen/src/index.ts index 8d0a77ed6d7..cd440f6b919 100644 --- a/packages/amplify-migration-template-gen/src/index.ts +++ b/packages/amplify-migration-template-gen/src/index.ts @@ -1 +1,2 @@ export * from './template-generator'; +export { ResourceMapping } from './types'; diff --git a/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.test.ts b/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.test.ts index a1aebe970ed..a8a9d8610ff 100644 --- a/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.test.ts +++ b/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.test.ts @@ -59,6 +59,12 @@ describe('CFNOutputResolver', () => { ], }, }, + snsTopicArn: { + Description: 'SnsTopicArn', + Value: { + Ref: 'snstopic', + }, + }, }, Resources: { MyS3Bucket: { @@ -169,6 +175,30 @@ describe('CFNOutputResolver', () => { }, }, }, + sqsqueue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + 'Fn::Join': ['', ['sqs-queue-amplifyCodegen-', 'dev']], + }, + }, + }, + snsSubscription: { + Type: 'AWS::SNS::Subscription', + Properties: { + Endpoint: { + 'Fn::GetAtt': ['sqsqueue', 'Arn'], + }, + Protocol: 'sqs', + TopicArn: { Ref: 'snsTopic' }, + }, + }, + snsTopic: { + Type: 'AWS::SNS::Topic', + Properties: { + TopicName: 'snsTopic', + }, + }, }, }; const expectedTemplate: CFNTemplate = { @@ -208,6 +238,10 @@ describe('CFNOutputResolver', () => { Description: 'HostedUIDomain', Value: 'my-hosted-UI-domain', }, + snsTopicArn: { + Description: 'SnsTopicArn', + Value: 'arn:aws:sns:us-east-1:12345:snsTopic', + }, }, Resources: { MyS3Bucket: { @@ -316,6 +350,28 @@ describe('CFNOutputResolver', () => { }, }, }, + sqsqueue: { + Type: 'AWS::SQS::Queue', + Properties: { + QueueName: { + 'Fn::Join': ['', ['sqs-queue-amplifyCodegen-', 'dev']], + }, + }, + }, + snsSubscription: { + Type: 'AWS::SNS::Subscription', + Properties: { + Endpoint: 'arn:aws:sqs:us-east-1:12345:physicalIdSqs', + Protocol: 'sqs', + TopicArn: { Ref: 'snsTopic' }, + }, + }, + snsTopic: { + Type: 'AWS::SNS::Topic', + Properties: { + TopicName: 'snsTopic', + }, + }, }, }; it('should resolve output references', () => { @@ -351,6 +407,30 @@ describe('CFNOutputResolver', () => { OutputKey: 'HostedUIDomain', OutputValue: 'my-hosted-UI-domain', }, + { + OutputKey: 'snsTopicArn', + OutputValue: 'arn:aws:sns:us-east-1:12345:snsTopic', + }, + ], + [ + { + StackName: 'amplify-amplifycodegen-dev', + StackId: 'arn:aws:cloudformation:us-west-2:123456789:stack/amplify-amplifycodegen-dev', + LogicalResourceId: 'sqsqueue', + PhysicalResourceId: 'physicalIdSqs', + ResourceType: 'AWS::SQS::Queue', + Timestamp: new Date('2025-04-02T22:27:41.603000+00:00'), + ResourceStatus: 'CREATE_COMPLETE', + }, + { + StackName: 'amplify-amplifycodegen-dev', + StackId: 'arn:aws:cloudformation:us-west-2:123456789:stack/amplify-amplifycodegen-dev', + LogicalResourceId: 'snsSubscription', + PhysicalResourceId: 'physicalIdSns', + ResourceType: 'AWS::SNS::Subscription', + Timestamp: new Date('2025-04-02T22:27:41.603000+00:00'), + ResourceStatus: 'CREATE_COMPLETE', + }, ], ), ).toEqual(expectedTemplate); diff --git a/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.ts b/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.ts index ce83a711a93..3943f4b8e18 100644 --- a/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.ts +++ b/packages/amplify-migration-template-gen/src/resolvers/cfn-output-resolver.ts @@ -1,6 +1,6 @@ import { AWS_RESOURCE_ATTRIBUTES, CFN_RESOURCE_TYPES, CFNTemplate } from '../types'; import assert from 'node:assert'; -import { Output } from '@aws-sdk/client-cloudformation'; +import { Output, StackResource } from '@aws-sdk/client-cloudformation'; const REF = 'Ref'; const GET_ATT = 'Fn::GetAtt'; @@ -12,7 +12,7 @@ const GET_ATT = 'Fn::GetAtt'; class CfnOutputResolver { constructor(private readonly template: CFNTemplate, private readonly region: string, private readonly accountId: string) {} - public resolve(logicalResourceIds: string[], stackOutputs: Output[]): CFNTemplate { + public resolve(logicalResourceIds: string[], stackOutputs: Output[], stackResources: StackResource[]): CFNTemplate { const resources = this.template?.Resources; assert(resources); const clonedStackTemplate = JSON.parse(JSON.stringify(this.template)) as CFNTemplate; @@ -59,6 +59,44 @@ class CfnOutputResolver { } }); + const fnGetAttRegExp = new RegExp(`{"${GET_ATT}":\\["(?\\w+)","(?\\w+)"]}`, 'g'); + const fnGetAttRegExpResult = stackTemplateResourcesString.matchAll(fnGetAttRegExp); + + for (const fnGetAttRegExpResultItem of fnGetAttRegExpResult) { + const groups = fnGetAttRegExpResultItem.groups; + if (groups && groups.LogicalResourceId) { + const stackResourceWithMatchingLogicalId = stackResources.find( + (resource) => resource.LogicalResourceId === groups.LogicalResourceId, + ); + if (stackResourceWithMatchingLogicalId) { + const fnGetAttRegExpPerLogicalId = new RegExp(`{"${GET_ATT}":\\["${groups.LogicalResourceId}","(?\\w+)"]}`, 'g'); + const stackResourcePhysicalId = stackResourceWithMatchingLogicalId.PhysicalResourceId; + assert(stackResourcePhysicalId); + if (groups.AttributeName === 'Arn') { + const resourceId = stackResourcePhysicalId.startsWith('http') ? stackResourcePhysicalId.split('/')[2] : stackResourcePhysicalId; + const resourceArn = this.getResourceAttribute( + groups.AttributeName, + stackResourceWithMatchingLogicalId.ResourceType as CFN_RESOURCE_TYPES, + resourceId, + ); + if (resourceArn) { + stackTemplateResourcesString = stackTemplateResourcesString.replaceAll(fnGetAttRegExpPerLogicalId, `"${resourceArn.Arn}"`); + } else { + stackTemplateResourcesString = stackTemplateResourcesString.replaceAll( + fnGetAttRegExpPerLogicalId, + `"${stackResourcePhysicalId}"`, + ); + } + } else { + stackTemplateResourcesString = stackTemplateResourcesString.replaceAll( + fnGetAttRegExpPerLogicalId, + `"${stackResourcePhysicalId}"`, + ); + } + } + } + } + clonedStackTemplate.Resources = JSON.parse(stackTemplateResourcesString); Object.entries(clonedStackTemplate.Outputs).forEach(([outputKey]) => { const stackOutputValue = stackOutputs?.find((op) => op.OutputKey === outputKey)?.OutputValue; @@ -100,6 +138,10 @@ class CfnOutputResolver { ? resourceIdentifier : `arn:aws:iam::${this.accountId}:role/${resourceIdentifier}`, }; + case 'AWS::SQS::Queue': + return { + Arn: `arn:aws:sqs:${this.region}:${this.accountId}:${resourceIdentifier}`, + }; default: return undefined; } diff --git a/packages/amplify-migration-template-gen/src/template-generator.test.ts b/packages/amplify-migration-template-gen/src/template-generator.test.ts index 232adc84a96..ac36e6748eb 100644 --- a/packages/amplify-migration-template-gen/src/template-generator.test.ts +++ b/packages/amplify-migration-template-gen/src/template-generator.test.ts @@ -480,6 +480,32 @@ describe('TemplateGenerator', () => { assertCFNCalls(false, ['auth']); }); + it('should refactor custom resources from Gen1 to Gen2 successfully', async () => { + // Arrange + const customResourceMap = [ + { + Source: { LogicalResourceId: 'CustomResource1', StackName: GEN1_ROOT_STACK_NAME }, + Destination: { LogicalResourceId: 'CustomResource1', StackName: GEN2_ROOT_STACK_NAME }, + }, + ]; + // Act + const generator = new TemplateGenerator( + GEN1_ROOT_STACK_NAME, + GEN2_ROOT_STACK_NAME, + ACCOUNT_ID, + STUB_CFN_CLIENT, + STUB_SSM_CLIENT, + STUB_COGNITO_IDP_CLIENT, + APP_ID, + ENV_NAME, + ); + await generator.generate(customResourceMap); + + // Assert + successfulCustomResourcesAssertions(); + assertCFNCalls(); + }); + it('should fail to generate when no applicable categories are found', async () => { const generator = new TemplateGenerator( GEN1_ROOT_STACK_NAME, @@ -808,6 +834,7 @@ describe('TemplateGenerator', () => { CFN_AUTH_TYPE.IdentityPoolRoleAttachment, CFN_AUTH_TYPE.UserPoolDomain, ], + expect.any(Function), ); expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( 2, @@ -821,6 +848,7 @@ describe('TemplateGenerator', () => { APP_ID, ENV_NAME, [CFN_AUTH_TYPE.UserPoolGroup], + expect.any(Function), ); expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( 3, @@ -834,6 +862,7 @@ describe('TemplateGenerator', () => { APP_ID, ENV_NAME, [CFN_S3_TYPE.Bucket], + expect.any(Function), ); } @@ -863,6 +892,7 @@ describe('TemplateGenerator', () => { CFN_AUTH_TYPE.IdentityPoolRoleAttachment, CFN_AUTH_TYPE.UserPoolDomain, ], + expect.any(Function), ); expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( 2, @@ -876,6 +906,7 @@ describe('TemplateGenerator', () => { APP_ID, ENV_NAME, [CFN_AUTH_TYPE.UserPoolGroup], + expect.any(Function), ); expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( 3, @@ -889,6 +920,79 @@ describe('TemplateGenerator', () => { APP_ID, ENV_NAME, [CFN_S3_TYPE.Bucket], + expect.any(Function), + ); + } + + function successfulCustomResourcesAssertions() { + expect(fs.mkdir).toBeCalledTimes(1); + expect(mockGenerateGen1PreProcessTemplate).toBeCalledTimes(4); + expect(mockGenerateGen2ResourceRemovalTemplate).toBeCalledTimes(4); + expect(mockGenerateStackRefactorTemplates).toBeCalledTimes(3); + expect(mockReadMeInitialize).toBeCalledTimes(1); + expect(mockReadMeRenderStep1).toBeCalledTimes(1); + expect(CategoryTemplateGenerator).toBeCalledTimes(4); + expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( + 1, + GEN1_AUTH_STACK_ID, + GEN2_AUTH_STACK_ID, + REGION, + ACCOUNT_ID, + STUB_CFN_CLIENT, + STUB_SSM_CLIENT, + STUB_COGNITO_IDP_CLIENT, + APP_ID, + ENV_NAME, + [ + CFN_AUTH_TYPE.UserPool, + CFN_AUTH_TYPE.UserPoolClient, + CFN_AUTH_TYPE.IdentityPool, + CFN_AUTH_TYPE.IdentityPoolRoleAttachment, + CFN_AUTH_TYPE.UserPoolDomain, + ], + expect.any(Function), + ); + expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( + 2, + GEN1_AUTH_USER_POOL_GROUP_STACK_ID, + GEN2_AUTH_STACK_ID, + REGION, + ACCOUNT_ID, + STUB_CFN_CLIENT, + STUB_SSM_CLIENT, + STUB_COGNITO_IDP_CLIENT, + APP_ID, + ENV_NAME, + [CFN_AUTH_TYPE.UserPoolGroup], + expect.any(Function), + ); + expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( + 3, + GEN1_STORAGE_STACK_ID, + GEN2_STORAGE_STACK_ID, + REGION, + ACCOUNT_ID, + STUB_CFN_CLIENT, + STUB_SSM_CLIENT, + STUB_COGNITO_IDP_CLIENT, + APP_ID, + ENV_NAME, + [CFN_S3_TYPE.Bucket], + expect.any(Function), + ); + expect(CategoryTemplateGenerator).toHaveBeenNthCalledWith( + 4, + GEN1_ROOT_STACK_NAME, + GEN2_ROOT_STACK_NAME, + REGION, + ACCOUNT_ID, + STUB_CFN_CLIENT, + STUB_SSM_CLIENT, + STUB_COGNITO_IDP_CLIENT, + APP_ID, + ENV_NAME, + [], + expect.any(Function), ); } diff --git a/packages/amplify-migration-template-gen/src/template-generator.ts b/packages/amplify-migration-template-gen/src/template-generator.ts index 078c3c0675b..bf88452b8fa 100644 --- a/packages/amplify-migration-template-gen/src/template-generator.ts +++ b/packages/amplify-migration-template-gen/src/template-generator.ts @@ -10,6 +10,7 @@ import CategoryTemplateGenerator from './category-template-generator'; import fs from 'node:fs/promises'; import { CATEGORY, + NON_CUSTOM_RESOURCE_CATEGORY, CFN_AUTH_TYPE, CFN_CATEGORY_TYPE, CFN_IAM_TYPE, @@ -19,6 +20,7 @@ import { CFNStackStatus, CFNTemplate, GEN2_AUTH_LOGICAL_RESOURCE_ID, + ResourceMapping, } from './types'; import MigrationReadmeGenerator from './migration-readme-generator'; import { pollStackForCompletionState, tryUpdateStack } from './cfn-stack-updater'; @@ -67,6 +69,7 @@ const CFN_FN_GET_ATTTRIBUTE = 'Fn::GetAtt'; const GEN1_USER_POOL_GROUPS_STACK_TYPE_DESCRIPTION = 'auth-Cognito-UserPool-Groups'; const GEN1_AUTH_STACK_TYPE_DESCRIPTION = 'auth-Cognito'; const NO_RESOURCES_TO_MOVE_ERROR = 'No resources to move'; +const NO_RESOURCES_TO_REMOVE_ERROR = 'No resources to remove'; class TemplateGenerator { private readonly categoryStackMap: Map; @@ -102,11 +105,16 @@ class TemplateGenerator { this.region = await this.cfnClient.config.region(); } - public async generate() { + public async generate(customResourceMap?: ResourceMapping[]) { await fs.mkdir(TEMPLATES_DIR, { recursive: true }); await this.setRegion(); await this.parseCategoryStacks(); - return await this.generateCategoryTemplates(); + if (customResourceMap) { + for (const { Source, Destination } of customResourceMap) { + this.updateCategoryStackMap(Source.LogicalResourceId, Source.StackName, Destination.StackName, false, false); + } + } + return await this.generateCategoryTemplates(false, customResourceMap); } public async revert() { @@ -185,7 +193,7 @@ class TemplateGenerator { } private updateCategoryStackMap( - category: CATEGORY, + category: CATEGORY | string, sourcePhysicalResourceId: string, destinationPhysicalResourceId: string, isUserPoolGroupStack: boolean, @@ -234,7 +242,8 @@ class TemplateGenerator { typeof error === 'object' && error !== null && 'message' in error && - (error as { message: string }).message.includes(NO_RESOURCES_TO_MOVE_ERROR) + typeof error.message === 'string' && + (error.message.includes(NO_RESOURCES_TO_MOVE_ERROR) || error.message.includes(NO_RESOURCES_TO_REMOVE_ERROR)) ); } @@ -274,19 +283,30 @@ class TemplateGenerator { oldTemplate: CFNTemplate; parameters?: Parameter[]; }> { - const { newTemplate, oldTemplate, parameters } = await categoryTemplateGenerator.generateGen2ResourceRemovalTemplate(); + + try { + const { newTemplate, oldTemplate, parameters } = await categoryTemplateGenerator.generateGen2ResourceRemovalTemplate(); - const updatingGen2CategoryStack = ora(`Updating Gen2 ${category} stack...`).start(); + const updatingGen2CategoryStack = ora(`Updating Gen2 ${category} stack...`).start(); - const gen2StackUpdateStatus = await tryUpdateStack(this.cfnClient, destinationCategoryStackId, parameters ?? [], newTemplate); + const gen2StackUpdateStatus = await tryUpdateStack(this.cfnClient, destinationCategoryStackId, parameters ?? [], newTemplate); - assert(gen2StackUpdateStatus === CFNStackStatus.UPDATE_COMPLETE); - updatingGen2CategoryStack.succeed(`Updated Gen2 ${category} stack successfully`); + assert(gen2StackUpdateStatus === CFNStackStatus.UPDATE_COMPLETE); + updatingGen2CategoryStack.succeed(`Updated Gen2 ${category} stack successfully`); - return { newTemplate, oldTemplate, parameters }; + return { newTemplate, oldTemplate, parameters }; + } catch (e) { + if (this.isNoResourcesError(e)) { + const currentTemplate = categoryTemplateGenerator.gen2Template; + assert(currentTemplate); + const parameters = categoryTemplateGenerator.gen2StackParameters; + return { newTemplate: currentTemplate, oldTemplate: currentTemplate, parameters }; + } + throw e; + } } - private initializeCategoryGenerators() { + private initializeCategoryGenerators(customResourceMap?: ResourceMapping[]) { assert(this.region); for (const [category, [sourceStackId, destinationStackId]] of this.categoryStackMap.entries()) { @@ -299,6 +319,13 @@ class TemplateGenerator { destinationStackId, this.createCategoryTemplateGenerator(sourceStackId, destinationStackId, config.resourcesToRefactor), ]); + } else if (customResourceMap && !Object.values(NON_CUSTOM_RESOURCE_CATEGORY).includes(category as NON_CUSTOM_RESOURCE_CATEGORY)) { + this.categoryTemplateGenerators.push([ + category, + sourceStackId, + destinationStackId, + this.createCategoryTemplateGenerator(sourceStackId, destinationStackId, [], customResourceMap), + ]); } } } @@ -307,6 +334,7 @@ class TemplateGenerator { sourceStackId: string, destinationStackId: string, resourcesToRefactor: CFN_CATEGORY_TYPE[], + customResourceMap?: ResourceMapping[], ): CategoryTemplateGenerator { assert(this.region); return new CategoryTemplateGenerator( @@ -320,11 +348,22 @@ class TemplateGenerator { this.appId, this.environmentName, resourcesToRefactor, + (_resourcesToMove: CFN_CATEGORY_TYPE[], cfnResource: [string, CFNResource]) => { + const [logicalId] = cfnResource; + + // Check if customResourceMap contains the logical ID + return ( + customResourceMap?.some( + (resourceMapping) => + resourceMapping.Source.LogicalResourceId === logicalId || resourceMapping.Destination.LogicalResourceId === logicalId, + ) ?? false + ); + }, ); } - private async generateCategoryTemplates(isRevert = false) { - this.initializeCategoryGenerators(); + private async generateCategoryTemplates(isRevert = false, customResourceMap?: ResourceMapping[]) { + this.initializeCategoryGenerators(customResourceMap); for (const [category, sourceCategoryStackId, destinationCategoryStackId, categoryTemplateGenerator] of this .categoryTemplateGenerators) { let newSourceTemplate: CFNTemplate | undefined; @@ -335,7 +374,36 @@ class TemplateGenerator { let destinationTemplateForRefactor: CFNTemplate | undefined; let logicalIdMappingForRefactor: Map | undefined; - if (!isRevert) { + if (customResourceMap && !Object.values(NON_CUSTOM_RESOURCE_CATEGORY).includes(category as NON_CUSTOM_RESOURCE_CATEGORY)) { + newSourceTemplate = await this.processGen1Stack(category, categoryTemplateGenerator, sourceCategoryStackId); + if (!newSourceTemplate) continue; + const { newTemplate } = await this.processGen2Stack(category, categoryTemplateGenerator, destinationCategoryStackId); + + newDestinationTemplate = newTemplate; + + const sourceToDestinationMap = new Map(); + + for (const resourceMapping of customResourceMap) { + const sourceLogicalId = resourceMapping.Source.LogicalResourceId; + const destinationLogicalId = resourceMapping.Destination.LogicalResourceId; + + if (sourceLogicalId && destinationLogicalId) { + sourceToDestinationMap.set(sourceLogicalId, destinationLogicalId); + } + } + + const { sourceTemplate, destinationTemplate, logicalIdMapping } = categoryTemplateGenerator.generateRefactorTemplates( + categoryTemplateGenerator.gen1ResourcesToMove, + categoryTemplateGenerator.gen2ResourcesToRemove, + newSourceTemplate, + newDestinationTemplate, + sourceToDestinationMap, + ); + + sourceTemplateForRefactor = sourceTemplate; + destinationTemplateForRefactor = destinationTemplate; + logicalIdMappingForRefactor = logicalIdMapping; + } else if (!isRevert) { newSourceTemplate = await this.processGen1Stack(category, categoryTemplateGenerator, sourceCategoryStackId); if (!newSourceTemplate) continue; const { newTemplate, oldTemplate, parameters } = await this.processGen2Stack( @@ -379,6 +447,7 @@ class TemplateGenerator { assert(newSourceTemplate); assert(newDestinationTemplate); + const refactorResources = ora(`Moving ${category} resources from ${this.getSourceToDestinationMessage(isRevert)} stack...`).start(); const { success, failedRefactorMetadata } = await this.refactorResources( logicalIdMappingForRefactor, @@ -419,7 +488,7 @@ class TemplateGenerator { logicalIdMappingForRefactor: Map, sourceCategoryStackId: string, destinationCategoryStackId: string, - category: 'auth' | 'storage' | 'auth-user-pool-group', + category: 'auth' | 'storage' | 'auth-user-pool-group' | string, isRevert: boolean, sourceTemplateForRefactor: CFNTemplate, destinationTemplateForRefactor: CFNTemplate, @@ -492,7 +561,7 @@ class TemplateGenerator { newSourceTemplateWithParametersResolved, this.region, this.accountId, - ).resolve(sourceLogicalIds, Outputs); + ).resolve(sourceLogicalIds, Outputs, []); const newSourceTemplateWithDepsResolved = new CfnDependencyResolver(newSourceTemplateWithOutputsResolved).resolve(sourceLogicalIds); if (category === 'auth' || category === 'auth-user-pool-group') { const { StackResources: AuthStackResources } = await this.cfnClient.send( diff --git a/packages/amplify-migration-template-gen/src/types.ts b/packages/amplify-migration-template-gen/src/types.ts index 8f37cc97fdd..0480162f1bc 100644 --- a/packages/amplify-migration-template-gen/src/types.ts +++ b/packages/amplify-migration-template-gen/src/types.ts @@ -66,7 +66,17 @@ export interface CFNStackRefactorTemplates { logicalIdMapping: Map; } -export type CATEGORY = 'auth' | 'storage' | 'auth-user-pool-group'; +export enum NON_CUSTOM_RESOURCE_CATEGORY { + AUTH = 'auth', + STORAGE = 'storage', + AUTH_USER_POOL_GROUP = 'auth-user-pool-group', +} + +export type CATEGORY = + | NON_CUSTOM_RESOURCE_CATEGORY.AUTH + | NON_CUSTOM_RESOURCE_CATEGORY.STORAGE + | NON_CUSTOM_RESOURCE_CATEGORY.AUTH_USER_POOL_GROUP + | string; export interface ResourceMappingLocation { StackName: string; @@ -95,11 +105,15 @@ export enum CFN_IAM_TYPE { Role = 'AWS::IAM::Role', } -export type CFN_RESOURCE_TYPES = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_IAM_TYPE; +export enum CFN_SQS_TYPE { + Queue = 'AWS::SQS::Queue', +} + +export type CFN_RESOURCE_TYPES = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_IAM_TYPE | CFN_SQS_TYPE; export type AWS_RESOURCE_ATTRIBUTES = 'Arn'; -export type CFN_CATEGORY_TYPE = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_IAM_TYPE; +export type CFN_CATEGORY_TYPE = CFN_AUTH_TYPE | CFN_S3_TYPE | CFN_IAM_TYPE | string; export enum CFN_PSEUDO_PARAMETERS_REF { StackName = 'AWS::StackName', diff --git a/packages/amplify-migration/README.md b/packages/amplify-migration/README.md index 568b551b0e8..ef65bef1905 100644 --- a/packages/amplify-migration/README.md +++ b/packages/amplify-migration/README.md @@ -11,3 +11,6 @@ Once this command runs successfully, the Gen1 project is converted to Gen2 with For executing the migration of resources from Gen1 to Gen2, run the following command: `npx @aws-amplify/migrate to-gen-2 execute --from --to ` + +For moving the resources back from Gen2 to Gen1, run the following command: +`npx @aws-amplify/migrate to-gen-2 revert --from --to ` diff --git a/packages/amplify-migration/src/command-handlers.ts b/packages/amplify-migration/src/command-handlers.ts index 3c0ffd942f3..787c232cfef 100644 --- a/packages/amplify-migration/src/command-handlers.ts +++ b/packages/amplify-migration/src/command-handlers.ts @@ -30,6 +30,7 @@ import { AppFunctionsDefinitionFetcher } from './app_functions_definition_fetche import { TemplateGenerator } from '@aws-amplify/migrate-template-gen'; import { printer } from './printer'; import { format } from './format'; +import { ResourceMapping } from '@aws-amplify/migrate-template-gen'; import ora from 'ora'; interface CodegenCommandParameters { @@ -54,7 +55,6 @@ export const GEN1_CONFIGURATION_FILES = ['aws-exports.js', 'amplifyconfiguration const CUSTOM_DIR = 'custom'; const TYPES_DIR = 'types'; const BACKEND_DIR = 'backend'; -const AMPLIFY_GEN_1_ENV_NAME = process.env.AMPLIFY_GEN_1_ENV_NAME; enum GEN2_AMPLIFY_GITIGNORE_FILES_OR_DIRS { DOT_AMPLIFY = '.amplify', @@ -79,7 +79,7 @@ const generateGen2Code = async ({ storage: await storageDefinitionFetcher.getDefinition(), data: await dataDefinitionFetcher.getDefinition(), functions: await functionsDefinitionFetcher.getDefinition(), - customResources: getCustomResources(), + customResources: await getCustomResourceMap(), unsupportedCategories: unsupportedCategories(), }; fetchingAWSResourceDetails.succeed('Fetched resource details from AWS'); @@ -297,6 +297,27 @@ const getCustomResources = (): string[] => { return customCategory ? Object.keys(customCategory) : []; }; +const getCustomResourceMap = async (): Promise> => { + const customResources = getCustomResources(); + const customResourceMap = new Map(); + + const rootDir = pathManager.findProjectRoot(); + assert(rootDir); + const amplifyGen1BackendDir = path.join(rootDir, AMPLIFY_DIR, BACKEND_DIR); + const sourceCustomResourcePath = path.join(amplifyGen1BackendDir, CUSTOM_DIR); + + for (const resource of customResources) { + const cdkStackFilePath = path.join(sourceCustomResourcePath, resource, 'cdk-stack.ts'); + const cdkStackContent = await fs.readFile(cdkStackFilePath, { encoding: 'utf-8' }); + const className = cdkStackContent.match(/export class (\w+)/)?.[1]; + if (className) { + customResourceMap.set(resource, className); + } + } + + return customResourceMap; +}; + export async function updateCustomResources() { const customResources = getCustomResources(); if (customResources.length > 0) { @@ -331,6 +352,7 @@ export async function updateCdkStackFile(customResources: string[], destinationC for (const resource of customResources) { const cdkStackFilePath = path.join(destinationCustomResourcePath, resource, 'cdk-stack.ts'); + const amplifyHelpersImport = /import\s+\*\s+as\s+AmplifyHelpers\s+from\s+['"]@aws-amplify\/cli-extensibility-helper['"];\n?/; try { @@ -339,14 +361,14 @@ export async function updateCdkStackFile(customResources: string[], destinationC // Check for existence of AmplifyHelpers.addResourceDependency and throw an error if found if (cdkStackContent.includes('AmplifyHelpers.addResourceDependency')) { cdkStackContent = cdkStackContent.replace( - /export class cdkStack/, - `throw new Error('Follow https://docs.amplify.aws/react/start/migrate-to-gen2/ to update the resource dependency');\n\nexport class cdkStack`, + /export class/, + `throw new Error('Follow https://docs.amplify.aws/react/start/migrate-to-gen2/ to update the resource dependency');\n\nexport class`, ); } cdkStackContent = cdkStackContent.replace( - /export class cdkStack/, - `const AMPLIFY_GEN_1_ENV_NAME = ${AMPLIFY_GEN_1_ENV_NAME} ?? "sandbox";\n\nexport class cdkStack`, + /export class/, + `const AMPLIFY_GEN_1_ENV_NAME = process.env.AMPLIFY_GEN_1_ENV_NAME ?? "sandbox";\n\nexport class`, ); cdkStackContent = cdkStackContent.replace(/extends cdk.Stack/, `extends cdk.NestedStack`); @@ -452,7 +474,7 @@ export async function execute() { await updateGitIgnoreForGen2(); await removeGen1ConfigurationFiles(); - + await updateCustomResources(); const movingGen1BackendFiles = ora(`Moving your Gen1 backend files to ${format.highlight(MIGRATION_DIR)}`).start(); @@ -497,9 +519,9 @@ export async function removeGen1ConfigurationFiles() { * @param fromStack * @param toStack */ -export async function executeStackRefactor(fromStack: string, toStack: string) { +export async function executeStackRefactor(fromStack: string, toStack: string, customResourceMap?: ResourceMapping[]) { const [templateGenerator, envName] = await initializeTemplateGenerator(fromStack, toStack); - const success = await templateGenerator.generate(); + const success = await templateGenerator.generate(customResourceMap); const usageData = await getUsageDataMetric(envName); if (success) { printer.print(format.success(`Generated .README file(s) successfully under ${MIGRATION_DIR}/templates directory.`)); diff --git a/packages/amplify-migration/src/commands/gen2/execute/execute_command.ts b/packages/amplify-migration/src/commands/gen2/execute/execute_command.ts index 2a9ffb8555a..18cd06006ca 100644 --- a/packages/amplify-migration/src/commands/gen2/execute/execute_command.ts +++ b/packages/amplify-migration/src/commands/gen2/execute/execute_command.ts @@ -1,10 +1,13 @@ import { ArgumentsCamelCase, Argv, CommandModule } from 'yargs'; +import { promises as fs } from 'fs'; import { executeStackRefactor } from '../../../command-handlers'; import assert from 'node:assert'; +import { ResourceMapping } from '@aws-amplify/migrate-template-gen'; export interface ExecuteCommandOptions { from: string | undefined; to: string | undefined; + customResourceMap: string | undefined; // New argument } /** @@ -38,12 +41,29 @@ export class Gen2ExecuteCommand implements CommandModule): Promise => { - const { from, to } = args; + const { from, to, customResourceMap } = args; assert(from); assert(to); - await executeStackRefactor(from, to); + + let parsedcustomResourceMap: ResourceMapping[] | undefined = undefined; + + if (customResourceMap) { + try { + const fileContent = await fs.readFile(customResourceMap, { encoding: 'utf-8' }); + parsedcustomResourceMap = JSON.parse(fileContent) as ResourceMapping[]; + } catch (error) { + throw new Error(`Failed to load customResourceMap from ${customResourceMap}: ${error.message}`); + } + } + + await executeStackRefactor(from, to, parsedcustomResourceMap); }; }