Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/amplify-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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).',
});
});
});
});
Original file line number Diff line number Diff line change
@@ -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) {}
Expand All @@ -26,8 +28,26 @@ export class AmplifyGen2MigrationValidations {
}

// eslint-disable-next-line spellcheck/spell-checker
public async validateStatefulResources(): Promise<void> {
printer.warn('Not implemented');
public async validateStatefulResources(changeSet: DescribeChangeSetOutput): Promise<void> {
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<void> {
Expand Down
Loading
Loading