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 new file mode 100644 index 00000000000..9def5a7c194 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts @@ -0,0 +1,320 @@ +import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; + +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: DescribeChangeSetOutput = {}; + await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow(); + }); + + it('should pass when changes exist but no stateful resources are removed', async () => { + const changeSet: 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: 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: 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: 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: 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: 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: 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: 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(); + }); + + 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 fe1ff15ec95..4d4a65cc0a7 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 { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation'; +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: 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', +]); 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