Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations';
import { $TSContext } from '@aws-amplify/amplify-cli-core';
import { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation';
import { CloudFormationClient, DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation';

jest.mock('@aws-sdk/client-cloudformation');

describe('AmplifyGen2MigrationValidations', () => {
let mockContext: $TSContext;
Expand Down Expand Up @@ -317,4 +319,176 @@ describe('AmplifyGen2MigrationValidations', () => {
});
});
});

describe('validateStatefulResources - nested stacks', () => {
let mockSend: jest.Mock;

beforeEach(() => {
mockSend = jest.fn();
(CloudFormationClient as jest.Mock).mockImplementation(() => ({
send: mockSend,
}));
});

afterEach(() => {
jest.clearAllMocks();
});

it('should throw when nested stack contains stateful resources', async () => {
mockSend.mockResolvedValueOnce({
StackResources: [
{
ResourceType: 'AWS::DynamoDB::Table',
PhysicalResourceId: 'MyTable',
LogicalResourceId: 'Table',
},
],
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'AuthStack',
PhysicalResourceId: 'auth-stack',
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
name: 'DestructiveMigrationError',
message:
'Stateful resources scheduled for deletion: AuthStack (AWS::CloudFormation::Stack) containing: Table (AWS::DynamoDB::Table).',
});
});

it('should pass when nested stack contains only stateless resources', async () => {
mockSend.mockResolvedValueOnce({
StackResources: [
{
ResourceType: 'AWS::Lambda::Function',
PhysicalResourceId: 'MyFunction',
LogicalResourceId: 'Function',
},
],
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'LambdaStack',
PhysicalResourceId: 'lambda-stack',
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow();
});

it('should handle multiple levels of nested stacks', async () => {
mockSend.mockResolvedValueOnce({
StackResources: [
{
ResourceType: 'AWS::CloudFormation::Stack',
PhysicalResourceId: 'storage-nested-stack',
LogicalResourceId: 'StorageNestedStack',
},
],
});

mockSend.mockResolvedValueOnce({
StackResources: [
{
ResourceType: 'AWS::S3::Bucket',
PhysicalResourceId: 'my-bucket',
LogicalResourceId: 'Bucket',
},
],
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'StorageStack',
PhysicalResourceId: 'storage-stack',
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
name: 'DestructiveMigrationError',
});
});

it('should pass when nested stack is missing PhysicalResourceId', async () => {
const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'IncompleteStack',
PhysicalResourceId: undefined,
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow();
});

it('should handle mixed direct and nested stateful resources', async () => {
mockSend.mockResolvedValueOnce({
StackResources: [
{
ResourceType: 'AWS::Cognito::UserPool',
PhysicalResourceId: 'user-pool',
LogicalResourceId: 'UserPool',
},
],
});

const changeSet: DescribeChangeSetOutput = {
Changes: [
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::DynamoDB::Table',
LogicalResourceId: 'DirectTable',
},
},
{
Type: 'Resource',
ResourceChange: {
Action: 'Remove',
ResourceType: 'AWS::CloudFormation::Stack',
LogicalResourceId: 'AuthStack',
PhysicalResourceId: 'auth-stack',
},
},
],
};

await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
name: 'DestructiveMigrationError',
message: expect.stringContaining('DirectTable'),
});
});
});
});
47 changes: 35 additions & 12 deletions packages/amplify-cli/src/commands/gen2-migration/_validations.ts
Original file line number Diff line number Diff line change
@@ -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 { DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation';
import { CloudFormationClient, DescribeChangeSetOutput, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
import { STATEFUL_RESOURCES } from './stateful-resources';

export class AmplifyGen2MigrationValidations {
Expand Down Expand Up @@ -31,20 +31,27 @@ export class AmplifyGen2MigrationValidations {
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),
);
const statefulRemoves: string[] = [];
for (const change of changeSet.Changes) {
if (change.Type === 'Resource' && change.ResourceChange?.Action === 'Remove' && change.ResourceChange?.ResourceType) {
if (change.ResourceChange.ResourceType === 'AWS::CloudFormation::Stack' && change.ResourceChange.PhysicalResourceId) {
const nestedResources = await this.getStatefulResources(change.ResourceChange.PhysicalResourceId);
if (nestedResources.length > 0) {
statefulRemoves.push(
`${change.ResourceChange.LogicalResourceId} (${change.ResourceChange.ResourceType}) containing: ${nestedResources.join(
', ',
)}`,
);
}
} else if (STATEFUL_RESOURCES.has(change.ResourceChange.ResourceType)) {
statefulRemoves.push(`${change.ResourceChange.LogicalResourceId} (${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}.`,
message: `Stateful resources scheduled for deletion: ${statefulRemoves.join(', ')}.`,
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
});
}
Expand All @@ -53,4 +60,20 @@ export class AmplifyGen2MigrationValidations {
public async validateIngressTraffic(): Promise<void> {
printer.warn('Not implemented');
}

private async getStatefulResources(stackName: string): Promise<string[]> {
const statefulResources: string[] = [];
const cfn = new CloudFormationClient({});
const { StackResources } = await cfn.send(new DescribeStackResourcesCommand({ StackName: stackName }));

for (const resource of StackResources ?? []) {
if (resource.ResourceType === 'AWS::CloudFormation::Stack' && resource.PhysicalResourceId) {
const nested = await this.getStatefulResources(resource.PhysicalResourceId);
statefulResources.push(...nested);
} else if (resource.ResourceType && STATEFUL_RESOURCES.has(resource.ResourceType)) {
statefulResources.push(`${resource.LogicalResourceId} (${resource.ResourceType})`);
}
}
return statefulResources;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

export const STATEFUL_RESOURCES = new Set([
'AWS::Backup::BackupVault',
'AWS::CloudFormation::Stack',
// 'AWS::CloudFormation::Stack',
'AWS::Cognito::UserPool',
'AWS::DocDB::DBCluster',
'AWS::DocDB::DBInstance',
Expand Down
Loading