From 669f6fd01bf47cead6ff6f3fd7cfc9e7a1678dfe Mon Sep 17 00:00:00 2001 From: Eli Polonsky Date: Tue, 7 Oct 2025 11:28:26 -0400 Subject: [PATCH 1/3] chore: scaffolding migration --- packages/amplify-cli/amplify-plugin.json | 2 + packages/amplify-cli/src/commands/drift.ts | 13 +++ .../src/commands/gen2-migration.ts | 92 +++++++++++++++++++ .../src/commands/gen2-migration/_step.ts | 11 +++ .../commands/gen2-migration/_validations.ts | 36 ++++++++ .../src/commands/gen2-migration/cleanup.ts | 16 ++++ .../src/commands/gen2-migration/clone.ts | 16 ++++ .../commands/gen2-migration/decommission.ts | 16 ++++ .../src/commands/gen2-migration/generate.ts | 16 ++++ .../src/commands/gen2-migration/lock.ts | 16 ++++ .../src/commands/gen2-migration/refactor.ts | 16 ++++ .../src/commands/gen2-migration/shift.ts | 16 ++++ 12 files changed, 266 insertions(+) create mode 100644 packages/amplify-cli/src/commands/drift.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/_step.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/_validations.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/cleanup.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/clone.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/decommission.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/generate.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/lock.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/refactor.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/shift.ts diff --git a/packages/amplify-cli/amplify-plugin.json b/packages/amplify-cli/amplify-plugin.json index 6655b6bf209..f13de00b4d6 100644 --- a/packages/amplify-cli/amplify-plugin.json +++ b/packages/amplify-cli/amplify-plugin.json @@ -6,8 +6,10 @@ "configure", "console", "delete", + "drift", "diagnose", "env", + "gen2-migration", "export", "help", "init", diff --git a/packages/amplify-cli/src/commands/drift.ts b/packages/amplify-cli/src/commands/drift.ts new file mode 100644 index 00000000000..4232e8183b7 --- /dev/null +++ b/packages/amplify-cli/src/commands/drift.ts @@ -0,0 +1,13 @@ +import { $TSContext } from '@aws-amplify/amplify-cli-core'; + +export const run = async (context: $TSContext): Promise => { + return new AmplifyDriftDetector(context).detect(); +}; + +export class AmplifyDriftDetector { + constructor(private readonly context: $TSContext) {} + + public async detect(): Promise { + throw new Error('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration.ts b/packages/amplify-cli/src/commands/gen2-migration.ts new file mode 100644 index 00000000000..a0bed7c2125 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration.ts @@ -0,0 +1,92 @@ +import { AmplifyMigrationCloneStep } from './gen2-migration/clone'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { AmplifyMigrationStep } from './gen2-migration/_step'; +import { printer } from '@aws-amplify/amplify-prompts'; +import { AmplifyMigrationCleanupStep } from './gen2-migration/cleanup'; +import { AmplifyMigrationDecommissionStep } from './gen2-migration/decommission'; +import { AmplifyMigrationGenerateStep } from './gen2-migration/generate'; +import { AmplifyMigrationLockStep } from './gen2-migration/lock'; +import { AmplifyMigrationRefactorStep } from './gen2-migration/refactor'; +import { AmplifyMigrationShiftStep } from './gen2-migration/shift'; + +const STEPS = { + cleanup: { + class: AmplifyMigrationCleanupStep, + description: 'TODO', + }, + clone: { + class: AmplifyMigrationCloneStep, + description: 'TODO', + }, + decommission: { + class: AmplifyMigrationDecommissionStep, + description: 'TODO', + }, + generate: { + class: AmplifyMigrationGenerateStep, + description: 'TODO', + }, + lock: { + class: AmplifyMigrationLockStep, + description: 'TODO', + }, + refactor: { + class: AmplifyMigrationRefactorStep, + description: 'TODO', + }, + shift: { + class: AmplifyMigrationShiftStep, + description: 'TODO', + }, +}; + +export const run = async (context: $TSContext) => { + const step = STEPS[(context.input.subCommands ?? [])[0]]; + if (!step) { + displayHelp(context); + return; + } + + shiftParams(context); + + const implementation: AmplifyMigrationStep = new step.class(context); + + try { + printer.info('Validating'); + await implementation.validate(); + printer.info('Executing'); + await implementation.execute(); + } catch (error: unknown) { + printer.warn(`${error}. Rolling back.`); + await implementation.rollback(); + throw error; + } +}; + +function shiftParams(context) { + delete context.parameters.first; + delete context.parameters.second; + delete context.parameters.third; + const { subCommands } = context.input; + /* eslint-disable */ + if (subCommands && subCommands.length > 1) { + if (subCommands.length > 1) { + context.parameters.first = subCommands[1]; + } + if (subCommands.length > 2) { + context.parameters.second = subCommands[2]; + } + if (subCommands.length > 3) { + context.parameters.third = subCommands[3]; + } + } + /* eslint-enable */ +} + +function displayHelp(context: $TSContext) { + context.amplify.showHelp( + 'amplify gen2-migration ', + Object.entries(STEPS).map(([name, v]) => ({ name, description: v.description })), + ); + printer.info(''); +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/_step.ts b/packages/amplify-cli/src/commands/gen2-migration/_step.ts new file mode 100644 index 00000000000..4dab77be9d9 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/_step.ts @@ -0,0 +1,11 @@ +import { $TSContext } from '@aws-amplify/amplify-cli-core'; + +export abstract class AmplifyMigrationStep { + constructor(private readonly context: $TSContext) {} + + public abstract validate(): Promise; + + public abstract execute(): Promise; + + public abstract rollback(): Promise; +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts new file mode 100644 index 00000000000..fe1ff15ec95 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts @@ -0,0 +1,36 @@ +import { AmplifyDriftDetector } from '../drift'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyGen2MigrationValidations { + constructor(private readonly context: $TSContext) {} + + public async validateDrift(): Promise { + return new AmplifyDriftDetector(this.context).detect(); + } + + public async validateWorkingDirectory(): Promise { + printer.warn('Not implemented'); + } + + public async validateDeploymentStatus(): Promise { + printer.warn('Not implemented'); + } + + public async validateDeploymentVersion(): Promise { + printer.warn('Not implemented'); + } + + public async validateIsolatedEnvironment(): Promise { + printer.warn('Not implemented'); + } + + // eslint-disable-next-line spellcheck/spell-checker + public async validateStatefulResources(): Promise { + printer.warn('Not implemented'); + } + + public async validateIngressTraffic(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/cleanup.ts b/packages/amplify-cli/src/commands/gen2-migration/cleanup.ts new file mode 100644 index 00000000000..d9b1dc6fbcc --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/cleanup.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationCleanupStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/clone.ts b/packages/amplify-cli/src/commands/gen2-migration/clone.ts new file mode 100644 index 00000000000..feb7ea6d40a --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/clone.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationCloneStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/decommission.ts b/packages/amplify-cli/src/commands/gen2-migration/decommission.ts new file mode 100644 index 00000000000..1b256f51f11 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/decommission.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationDecommissionStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate.ts b/packages/amplify-cli/src/commands/gen2-migration/generate.ts new file mode 100644 index 00000000000..7753c730483 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/generate.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationGenerateStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/lock.ts b/packages/amplify-cli/src/commands/gen2-migration/lock.ts new file mode 100644 index 00000000000..c8726095559 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/lock.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationLockStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor.ts new file mode 100644 index 00000000000..dfc2de02c56 --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationRefactorStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} diff --git a/packages/amplify-cli/src/commands/gen2-migration/shift.ts b/packages/amplify-cli/src/commands/gen2-migration/shift.ts new file mode 100644 index 00000000000..e4c966bcc9b --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/shift.ts @@ -0,0 +1,16 @@ +import { AmplifyMigrationStep } from './_step'; +import { printer } from '@aws-amplify/amplify-prompts'; + +export class AmplifyMigrationShiftStep extends AmplifyMigrationStep { + public async validate(): Promise { + printer.warn('Not implemented'); + } + + public async execute(): Promise { + printer.warn('Not implemented'); + } + + public async rollback(): Promise { + printer.warn('Not implemented'); + } +} From d094764455e31b9d4a45875f93f8f37133001baf Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Fri, 24 Oct 2025 10:12:55 -0400 Subject: [PATCH 2/3] chore: add gen2 migration stateful resources validations and unit tests --- .../gen2-migration/_validations.test.ts | 253 ++++++++++++++++++ .../commands/gen2-migration/_validations.ts | 26 +- .../gen2-migration/stateful-resources.ts | 36 +++ 3 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts create mode 100644 packages/amplify-cli/src/commands/gen2-migration/stateful-resources.ts diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts new file mode 100644 index 00000000000..1731b6f494f --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts @@ -0,0 +1,253 @@ +import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { CloudFormation } from 'aws-sdk'; + +describe('AmplifyGen2MigrationValidations', () => { + let mockContext: $TSContext; + let validations: AmplifyGen2MigrationValidations; + + beforeEach(() => { + mockContext = {} as $TSContext; + validations = new AmplifyGen2MigrationValidations(mockContext); + }); + + describe('validateStatefulResources', () => { + it('should pass when no changes exist', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = {}; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should pass when changes exist but no stateful resources are removed', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::S3::Bucket', + LogicalResourceId: 'MyBucket', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should throw when stateful resource is removed', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::DynamoDB::Table', + LogicalResourceId: 'MyTable', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({ + name: 'DestructiveMigrationError', + message: 'Stateful resources scheduled for deletion: MyTable (AWS::DynamoDB::Table).', + resolution: 'Review the migration plan and ensure data is backed up before proceeding.', + }); + }); + + it('should throw when stateful resources are removed', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::S3::Bucket', + LogicalResourceId: 'Bucket1', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::Cognito::UserPool', + LogicalResourceId: 'UserPool1', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({ + name: 'DestructiveMigrationError', + message: 'Stateful resources scheduled for deletion: Bucket1 (AWS::S3::Bucket), UserPool1 (AWS::Cognito::UserPool).', + resolution: 'Review the migration plan and ensure data is backed up before proceeding.', + }); + }); + + it('should pass when non-stateful resource is removed', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::Lambda::Function', + LogicalResourceId: 'MyFunction', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should pass with realistic changeset containing mixed add and remove operations on stateless resources', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/1a2345b6-0000-00a0-a123-00abc0abc000', + Status: 'CREATE_COMPLETE', + ChangeSetName: 'SampleChangeSet-addremove', + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::AutoScaling::AutoScalingGroup', + LogicalResourceId: 'AutoScalingGroup', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::AutoScaling::LaunchConfiguration', + LogicalResourceId: 'LaunchConfig', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::EC2::Instance', + PhysicalResourceId: 'i-1abc23d4', + LogicalResourceId: 'MyEC2Instance', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should throw with realistic changeset containing remove operations on stateful resources', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/1a2345b6-0000-00a0-a123-00abc0abc000', + Status: 'CREATE_COMPLETE', + ChangeSetName: 'SampleChangeSet-removeVolume', + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::AutoScaling::AutoScalingGroup', + LogicalResourceId: 'AutoScalingGroup', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::AutoScaling::LaunchConfiguration', + LogicalResourceId: 'LaunchConfig', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::EC2::Volume', + PhysicalResourceId: 'vol-1abc23d4', + LogicalResourceId: 'MyEBSVolume', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({ + name: 'DestructiveMigrationError', + message: 'Stateful resources scheduled for deletion: MyEBSVolume (AWS::EC2::Volume).', + resolution: 'Review the migration plan and ensure data is backed up before proceeding.', + }); + }); + + it('should throw when removing three stateful resources', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::RDS::DBInstance', + LogicalResourceId: 'Database', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::DynamoDB::Table', + LogicalResourceId: 'UsersTable', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::Kinesis::Stream', + LogicalResourceId: 'EventStream', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({ + name: 'DestructiveMigrationError', + message: + 'Stateful resources scheduled for deletion: Database (AWS::RDS::DBInstance), UsersTable (AWS::DynamoDB::Table), EventStream (AWS::Kinesis::Stream).', + resolution: 'Review the migration plan and ensure data is backed up before proceeding.', + }); + }); + + it('should pass with remove operations on stateless and add on stateful', async () => { + const changeSet: CloudFormation.DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::S3::Bucket', + LogicalResourceId: 'NewBucket', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::DynamoDB::Table', + LogicalResourceId: 'NewTable', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::Lambda::Function', + LogicalResourceId: 'OldFunction', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::EC2::Instance', + LogicalResourceId: 'OldInstance', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts index fe1ff15ec95..97057688a9d 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts @@ -1,6 +1,8 @@ import { AmplifyDriftDetector } from '../drift'; -import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { $TSContext, AmplifyError } from '@aws-amplify/amplify-cli-core'; import { printer } from '@aws-amplify/amplify-prompts'; +import { CloudFormation } from 'aws-sdk'; +import { STATEFUL_RESOURCES } from './stateful-resources'; export class AmplifyGen2MigrationValidations { constructor(private readonly context: $TSContext) {} @@ -26,8 +28,26 @@ export class AmplifyGen2MigrationValidations { } // eslint-disable-next-line spellcheck/spell-checker - public async validateStatefulResources(): Promise { - printer.warn('Not implemented'); + public async validateStatefulResources(changeSet: CloudFormation.DescribeChangeSetOutput): Promise { + if (!changeSet.Changes) return; + + const statefulRemoves = changeSet.Changes.filter( + (change) => + change.Type === 'Resource' && + change.ResourceChange?.Action === 'Remove' && + change.ResourceChange?.ResourceType && + STATEFUL_RESOURCES.has(change.ResourceChange.ResourceType), + ); + + if (statefulRemoves.length > 0) { + const resources = statefulRemoves + .map((c) => `${c.ResourceChange?.LogicalResourceId ?? 'Unknown'} (${c.ResourceChange?.ResourceType})`) + .join(', '); + throw new AmplifyError('DestructiveMigrationError', { + message: `Stateful resources scheduled for deletion: ${resources}.`, + resolution: 'Review the migration plan and ensure data is backed up before proceeding.', + }); + } } public async validateIngressTraffic(): Promise { diff --git a/packages/amplify-cli/src/commands/gen2-migration/stateful-resources.ts b/packages/amplify-cli/src/commands/gen2-migration/stateful-resources.ts new file mode 100644 index 00000000000..a412720ac9b --- /dev/null +++ b/packages/amplify-cli/src/commands/gen2-migration/stateful-resources.ts @@ -0,0 +1,36 @@ +/** + * AWS CloudFormation resource types that contain stateful data. + * Deletion of these resources may result in permanent data loss. + */ + +export const STATEFUL_RESOURCES = new Set([ + 'AWS::Backup::BackupVault', + 'AWS::CloudFormation::Stack', + 'AWS::Cognito::UserPool', + 'AWS::DocDB::DBCluster', + 'AWS::DocDB::DBInstance', + 'AWS::DynamoDB::GlobalTable', + 'AWS::DynamoDB::Table', + 'AWS::EC2::Volume', + 'AWS::EFS::FileSystem', + 'AWS::EMR::Cluster', + 'AWS::ElastiCache::CacheCluster', + 'AWS::ElastiCache::ReplicationGroup', + 'AWS::Elasticsearch::Domain', + 'AWS::FSx::FileSystem', + 'AWS::KMS::Key', + 'AWS::Kinesis::Stream', + 'AWS::Logs::LogGroup', + 'AWS::Neptune::DBCluster', + 'AWS::Neptune::DBInstance', + 'AWS::OpenSearchService::Domain', + 'AWS::Organizations::Account', + 'AWS::QLDB::Ledger', + 'AWS::RDS::DBCluster', + 'AWS::RDS::DBInstance', + 'AWS::Redshift::Cluster', + 'AWS::S3::Bucket', + 'AWS::SDB::Domain', + 'AWS::SQS::Queue', + 'AWS::SecretsManager::Secret', +]); From 7c9d56a6c3df2dbc82cd14cbdd9d7f9e4d97b9d4 Mon Sep 17 00:00:00 2001 From: Sai Ray Date: Fri, 24 Oct 2025 12:41:09 -0400 Subject: [PATCH 3/3] chore: update gen2 migration validations to use AWS SDK v3 --- packages/amplify-cli/package.json | 1 + .../gen2-migration/_validations.test.ts | 87 ++++++++++++++++--- .../commands/gen2-migration/_validations.ts | 4 +- yarn.lock | 1 + 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/packages/amplify-cli/package.json b/packages/amplify-cli/package.json index 6b0f0f57656..e84d57b6781 100644 --- a/packages/amplify-cli/package.json +++ b/packages/amplify-cli/package.json @@ -67,6 +67,7 @@ "@aws-amplify/amplify-util-uibuilder": "1.14.21", "@aws-cdk/cloudformation-diff": "~2.68.0", "@aws-sdk/client-amplify": "^3.624.0", + "@aws-sdk/client-cloudformation": "^3.624.0", "@aws-sdk/client-cognito-identity-provider": "^3.624.0", "amplify-codegen": "^4.10.3", "amplify-dotnet-function-runtime-provider": "2.1.6", diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts index 1731b6f494f..9def5a7c194 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts @@ -1,6 +1,6 @@ import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations'; import { $TSContext } from '@aws-amplify/amplify-cli-core'; -import { CloudFormation } from 'aws-sdk'; +import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; describe('AmplifyGen2MigrationValidations', () => { let mockContext: $TSContext; @@ -13,12 +13,12 @@ describe('AmplifyGen2MigrationValidations', () => { describe('validateStatefulResources', () => { it('should pass when no changes exist', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = {}; + const changeSet: DescribeChangeSetOutput = {}; await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); }); it('should pass when changes exist but no stateful resources are removed', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { Changes: [ { Type: 'Resource', @@ -34,7 +34,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should throw when stateful resource is removed', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { Changes: [ { Type: 'Resource', @@ -54,7 +54,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should throw when stateful resources are removed', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { Changes: [ { Type: 'Resource', @@ -82,7 +82,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should pass when non-stateful resource is removed', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { Changes: [ { Type: 'Resource', @@ -98,7 +98,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should pass with realistic changeset containing mixed add and remove operations on stateless resources', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/1a2345b6-0000-00a0-a123-00abc0abc000', Status: 'CREATE_COMPLETE', ChangeSetName: 'SampleChangeSet-addremove', @@ -134,7 +134,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should throw with realistic changeset containing remove operations on stateful resources', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/MyStack/1a2345b6-0000-00a0-a123-00abc0abc000', Status: 'CREATE_COMPLETE', ChangeSetName: 'SampleChangeSet-removeVolume', @@ -174,7 +174,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should throw when removing three stateful resources', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { Changes: [ { Type: 'Resource', @@ -211,7 +211,7 @@ describe('AmplifyGen2MigrationValidations', () => { }); it('should pass with remove operations on stateless and add on stateful', async () => { - const changeSet: CloudFormation.DescribeChangeSetOutput = { + const changeSet: DescribeChangeSetOutput = { Changes: [ { Type: 'Resource', @@ -249,5 +249,72 @@ describe('AmplifyGen2MigrationValidations', () => { }; await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); }); + + it('should pass when modifying stateful resources', async () => { + const changeSet: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + ResourceType: 'AWS::DynamoDB::Table', + LogicalResourceId: 'MyTable', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should pass with mixed modify and add operations on stateful resources', async () => { + const changeSet: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + ResourceType: 'AWS::S3::Bucket', + LogicalResourceId: 'ExistingBucket', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Add', + ResourceType: 'AWS::RDS::DBInstance', + LogicalResourceId: 'NewDatabase', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should throw when removing stateful resource with mixed modify operations', async () => { + const changeSet: DescribeChangeSetOutput = { + Changes: [ + { + Type: 'Resource', + ResourceChange: { + Action: 'Modify', + ResourceType: 'AWS::DynamoDB::Table', + LogicalResourceId: 'ModifiedTable', + }, + }, + { + Type: 'Resource', + ResourceChange: { + Action: 'Remove', + ResourceType: 'AWS::S3::Bucket', + LogicalResourceId: 'DeletedBucket', + }, + }, + ], + }; + await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({ + name: 'DestructiveMigrationError', + message: 'Stateful resources scheduled for deletion: DeletedBucket (AWS::S3::Bucket).', + }); + }); }); }); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts index 97057688a9d..4d4a65cc0a7 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts @@ -1,7 +1,7 @@ import { AmplifyDriftDetector } from '../drift'; import { $TSContext, AmplifyError } from '@aws-amplify/amplify-cli-core'; import { printer } from '@aws-amplify/amplify-prompts'; -import { CloudFormation } from 'aws-sdk'; +import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; import { STATEFUL_RESOURCES } from './stateful-resources'; export class AmplifyGen2MigrationValidations { @@ -28,7 +28,7 @@ export class AmplifyGen2MigrationValidations { } // eslint-disable-next-line spellcheck/spell-checker - public async validateStatefulResources(changeSet: CloudFormation.DescribeChangeSetOutput): Promise { + public async validateStatefulResources(changeSet: DescribeChangeSetOutput): Promise { if (!changeSet.Changes) return; const statefulRemoves = changeSet.Changes.filter( diff --git a/yarn.lock b/yarn.lock index aaf9601456c..eadc4ff14d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1152,6 +1152,7 @@ __metadata: "@aws-amplify/amplify-util-uibuilder": 1.14.21 "@aws-cdk/cloudformation-diff": ~2.68.0 "@aws-sdk/client-amplify": ^3.624.0 + "@aws-sdk/client-cloudformation": ^3.624.0 "@aws-sdk/client-cognito-identity-provider": ^3.624.0 "@types/archiver": ^5.3.1 "@types/columnify": ^1.5.1