Skip to content

Commit 1570cf5

Browse files
sai-raySai Ray
andauthored
feat(gen2-migration): implement decommission command with optimized stateful resource validation (#14325)
chore: implement decommission with optimized stateful resource validation Co-authored-by: Sai Ray <[email protected]>
1 parent 9732436 commit 1570cf5

File tree

5 files changed

+231
-61
lines changed

5 files changed

+231
-61
lines changed

packages/amplify-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"amplify-nodejs-function-runtime-provider": "2.5.32",
8585
"amplify-python-function-runtime-provider": "2.4.54",
8686
"aws-cdk-lib": "~2.189.1",
87+
"bottleneck": "2.19.5",
8788
"chalk": "^4.1.1",
8889
"ci-info": "^3.8.0",
8990
"cli-table3": "^0.6.0",

packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations.test.ts

Lines changed: 50 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@ import { $TSContext, stateManager } from '@aws-amplify/amplify-cli-core';
33
import { CloudFormationClient, DescribeChangeSetOutput } from '@aws-sdk/client-cloudformation';
44

55
jest.mock('@aws-sdk/client-cloudformation');
6+
jest.mock('bottleneck', () => {
7+
return jest.fn().mockImplementation(() => ({
8+
schedule: jest.fn((fn) => fn()),
9+
}));
10+
});
11+
jest.mock('@aws-amplify/amplify-prompts', () => ({
12+
printer: {
13+
error: jest.fn(),
14+
info: jest.fn(),
15+
warn: jest.fn(),
16+
},
17+
AmplifySpinner: jest.fn().mockImplementation(() => ({
18+
start: jest.fn(),
19+
stop: jest.fn(),
20+
})),
21+
}));
622
jest.mock('@aws-amplify/amplify-cli-core', () => ({
723
...jest.requireActual('@aws-amplify/amplify-cli-core'),
824
stateManager: {
@@ -20,6 +36,19 @@ describe('AmplifyGen2MigrationValidations', () => {
2036
});
2137

2238
describe('validateStatefulResources', () => {
39+
let mockSend: jest.Mock;
40+
41+
beforeEach(() => {
42+
mockSend = jest.fn();
43+
(CloudFormationClient as jest.Mock).mockImplementation(() => ({
44+
send: mockSend,
45+
}));
46+
});
47+
48+
afterEach(() => {
49+
jest.clearAllMocks();
50+
});
51+
2352
it('should pass when no changes exist', async () => {
2453
const changeSet: DescribeChangeSetOutput = {};
2554
await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow();
@@ -56,8 +85,8 @@ describe('AmplifyGen2MigrationValidations', () => {
5685
};
5786
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
5887
name: 'DestructiveMigrationError',
59-
message: 'Stateful resources scheduled for deletion: MyTable (AWS::DynamoDB::Table).',
60-
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
88+
message: 'Decommission will delete stateful resources.',
89+
resolution: 'Review the resources above and ensure data is backed up before proceeding.',
6190
});
6291
});
6392

@@ -84,8 +113,8 @@ describe('AmplifyGen2MigrationValidations', () => {
84113
};
85114
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
86115
name: 'DestructiveMigrationError',
87-
message: 'Stateful resources scheduled for deletion: Bucket1 (AWS::S3::Bucket), UserPool1 (AWS::Cognito::UserPool).',
88-
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
116+
message: 'Decommission will delete stateful resources.',
117+
resolution: 'Review the resources above and ensure data is backed up before proceeding.',
89118
});
90119
});
91120

@@ -176,8 +205,8 @@ describe('AmplifyGen2MigrationValidations', () => {
176205
};
177206
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
178207
name: 'DestructiveMigrationError',
179-
message: 'Stateful resources scheduled for deletion: MyEBSVolume (AWS::EC2::Volume).',
180-
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
208+
message: 'Decommission will delete stateful resources.',
209+
resolution: 'Review the resources above and ensure data is backed up before proceeding.',
181210
});
182211
});
183212

@@ -212,9 +241,8 @@ describe('AmplifyGen2MigrationValidations', () => {
212241
};
213242
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
214243
name: 'DestructiveMigrationError',
215-
message:
216-
'Stateful resources scheduled for deletion: Database (AWS::RDS::DBInstance), UsersTable (AWS::DynamoDB::Table), EventStream (AWS::Kinesis::Stream).',
217-
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
244+
message: 'Decommission will delete stateful resources.',
245+
resolution: 'Review the resources above and ensure data is backed up before proceeding.',
218246
});
219247
});
220248

@@ -321,34 +349,20 @@ describe('AmplifyGen2MigrationValidations', () => {
321349
};
322350
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
323351
name: 'DestructiveMigrationError',
324-
message: 'Stateful resources scheduled for deletion: DeletedBucket (AWS::S3::Bucket).',
352+
message: 'Decommission will delete stateful resources.',
325353
});
326354
});
327-
});
328-
329-
describe('validateStatefulResources - nested stacks', () => {
330-
let mockSend: jest.Mock;
331-
332-
beforeEach(() => {
333-
mockSend = jest.fn();
334-
(CloudFormationClient as jest.Mock).mockImplementation(() => ({
335-
send: mockSend,
336-
}));
337-
});
338-
339-
afterEach(() => {
340-
jest.clearAllMocks();
341-
});
342355

343356
it('should throw when nested stack contains stateful resources', async () => {
344357
mockSend.mockResolvedValueOnce({
345-
StackResources: [
358+
StackResourceSummaries: [
346359
{
347360
ResourceType: 'AWS::DynamoDB::Table',
348361
PhysicalResourceId: 'MyTable',
349362
LogicalResourceId: 'Table',
350363
},
351364
],
365+
NextToken: undefined,
352366
});
353367

354368
const changeSet: DescribeChangeSetOutput = {
@@ -367,20 +381,20 @@ describe('AmplifyGen2MigrationValidations', () => {
367381

368382
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
369383
name: 'DestructiveMigrationError',
370-
message:
371-
'Stateful resources scheduled for deletion: AuthStack (AWS::CloudFormation::Stack) containing: Table (AWS::DynamoDB::Table).',
384+
message: 'Decommission will delete stateful resources.',
372385
});
373386
});
374387

375388
it('should pass when nested stack contains only stateless resources', async () => {
376389
mockSend.mockResolvedValueOnce({
377-
StackResources: [
390+
StackResourceSummaries: [
378391
{
379392
ResourceType: 'AWS::Lambda::Function',
380393
PhysicalResourceId: 'MyFunction',
381394
LogicalResourceId: 'Function',
382395
},
383396
],
397+
NextToken: undefined,
384398
});
385399

386400
const changeSet: DescribeChangeSetOutput = {
@@ -402,23 +416,25 @@ describe('AmplifyGen2MigrationValidations', () => {
402416

403417
it('should handle multiple levels of nested stacks', async () => {
404418
mockSend.mockResolvedValueOnce({
405-
StackResources: [
419+
StackResourceSummaries: [
406420
{
407421
ResourceType: 'AWS::CloudFormation::Stack',
408422
PhysicalResourceId: 'storage-nested-stack',
409423
LogicalResourceId: 'StorageNestedStack',
410424
},
411425
],
426+
NextToken: undefined,
412427
});
413428

414429
mockSend.mockResolvedValueOnce({
415-
StackResources: [
430+
StackResourceSummaries: [
416431
{
417432
ResourceType: 'AWS::S3::Bucket',
418433
PhysicalResourceId: 'my-bucket',
419434
LogicalResourceId: 'Bucket',
420435
},
421436
],
437+
NextToken: undefined,
422438
});
423439

424440
const changeSet: DescribeChangeSetOutput = {
@@ -460,13 +476,14 @@ describe('AmplifyGen2MigrationValidations', () => {
460476

461477
it('should handle mixed direct and nested stateful resources', async () => {
462478
mockSend.mockResolvedValueOnce({
463-
StackResources: [
479+
StackResourceSummaries: [
464480
{
465481
ResourceType: 'AWS::Cognito::UserPool',
466482
PhysicalResourceId: 'user-pool',
467483
LogicalResourceId: 'UserPool',
468484
},
469485
],
486+
NextToken: undefined,
470487
});
471488

472489
const changeSet: DescribeChangeSetOutput = {
@@ -493,7 +510,7 @@ describe('AmplifyGen2MigrationValidations', () => {
493510

494511
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
495512
name: 'DestructiveMigrationError',
496-
message: expect.stringContaining('DirectTable'),
513+
message: 'Decommission will delete stateful resources.',
497514
});
498515
});
499516
});

packages/amplify-cli/src/commands/gen2-migration/_validations.ts

Lines changed: 111 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
import { AmplifyDriftDetector } from '../drift';
22
import { $TSContext, AmplifyError, stateManager } from '@aws-amplify/amplify-cli-core';
3-
import { printer } from '@aws-amplify/amplify-prompts';
3+
import { printer, AmplifySpinner } from '@aws-amplify/amplify-prompts';
44
import {
55
DescribeChangeSetOutput,
66
CloudFormationClient,
77
DescribeStacksCommand,
8-
DescribeStackResourcesCommand,
8+
ListStackResourcesCommand,
99
} from '@aws-sdk/client-cloudformation';
1010
import { STATEFUL_RESOURCES } from './stateful-resources';
11+
import CLITable from 'cli-table3';
12+
import Bottleneck from 'bottleneck';
1113
import execa from 'execa';
1214

1315
export class AmplifyGen2MigrationValidations {
16+
private spinner?: AmplifySpinner;
17+
private limiter = new Bottleneck({
18+
maxConcurrent: 3,
19+
minTime: 50,
20+
});
21+
1422
constructor(private readonly context: $TSContext) {}
1523

1624
// public async validateDrift(): Promise<void> {
@@ -86,28 +94,52 @@ export class AmplifyGen2MigrationValidations {
8694
public async validateStatefulResources(changeSet: DescribeChangeSetOutput): Promise<void> {
8795
if (!changeSet.Changes) return;
8896

89-
const statefulRemoves: string[] = [];
97+
this.spinner = new AmplifySpinner();
98+
this.spinner.start('Scanning for stateful resources...');
99+
100+
const statefulRemoves: Array<{ category: string; resourceType: string; physicalId: string }> = [];
90101
for (const change of changeSet.Changes) {
91102
if (change.Type === 'Resource' && change.ResourceChange?.Action === 'Remove' && change.ResourceChange?.ResourceType) {
92103
if (change.ResourceChange.ResourceType === 'AWS::CloudFormation::Stack' && change.ResourceChange.PhysicalResourceId) {
93-
const nestedResources = await this.getStatefulResources(change.ResourceChange.PhysicalResourceId);
94-
if (nestedResources.length > 0) {
95-
statefulRemoves.push(
96-
`${change.ResourceChange.LogicalResourceId} (${change.ResourceChange.ResourceType}) containing: ${nestedResources.join(
97-
', ',
98-
)}`,
99-
);
100-
}
104+
const category = this.extractCategory(change.ResourceChange.LogicalResourceId || '');
105+
this.spinner.start(`Scanning '${category}'...`);
106+
const nestedResources = await this.getStatefulResources(
107+
change.ResourceChange.PhysicalResourceId,
108+
change.ResourceChange.LogicalResourceId,
109+
);
110+
statefulRemoves.push(...nestedResources);
101111
} else if (STATEFUL_RESOURCES.has(change.ResourceChange.ResourceType)) {
102-
statefulRemoves.push(`${change.ResourceChange.LogicalResourceId} (${change.ResourceChange.ResourceType})`);
112+
const category = this.extractCategory(change.ResourceChange.LogicalResourceId || '');
113+
const physicalId = change.ResourceChange.PhysicalResourceId || 'N/A';
114+
this.spinner.start(`Scanning '${category}' category: found stateful resource "${physicalId}"`);
115+
statefulRemoves.push({
116+
category,
117+
resourceType: change.ResourceChange.ResourceType,
118+
physicalId,
119+
});
103120
}
104121
}
105122
}
106123

124+
this.spinner.stop();
125+
this.spinner = undefined;
126+
107127
if (statefulRemoves.length > 0) {
128+
const table = new CLITable({
129+
head: ['Category', 'Resource Type', 'Physical ID'],
130+
style: { head: ['red'] },
131+
});
132+
133+
statefulRemoves.forEach((resource) => {
134+
table.push([resource.category, resource.resourceType, resource.physicalId]);
135+
});
136+
137+
printer.error('\nStateful resources scheduled for deletion:\n');
138+
printer.info(table.toString());
139+
108140
throw new AmplifyError('DestructiveMigrationError', {
109-
message: `Stateful resources scheduled for deletion: ${statefulRemoves.join(', ')}.`,
110-
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
141+
message: 'Decommission will delete stateful resources.',
142+
resolution: 'Review the resources above and ensure data is backed up before proceeding.',
111143
});
112144
}
113145
}
@@ -116,19 +148,72 @@ export class AmplifyGen2MigrationValidations {
116148
printer.warn('Not implemented');
117149
}
118150

119-
private async getStatefulResources(stackName: string): Promise<string[]> {
120-
const statefulResources: string[] = [];
121-
const cfn = new CloudFormationClient({});
122-
const { StackResources } = await cfn.send(new DescribeStackResourcesCommand({ StackName: stackName }));
123-
124-
for (const resource of StackResources ?? []) {
125-
if (resource.ResourceType === 'AWS::CloudFormation::Stack' && resource.PhysicalResourceId) {
126-
const nested = await this.getStatefulResources(resource.PhysicalResourceId);
127-
statefulResources.push(...nested);
128-
} else if (resource.ResourceType && STATEFUL_RESOURCES.has(resource.ResourceType)) {
129-
statefulResources.push(`${resource.LogicalResourceId} (${resource.ResourceType})`);
151+
private async getStatefulResources(
152+
stackName: string,
153+
parentLogicalId?: string,
154+
): Promise<Array<{ category: string; resourceType: string; physicalId: string }>> {
155+
const statefulResources: Array<{ category: string; resourceType: string; physicalId: string }> = [];
156+
const cfn = new CloudFormationClient({
157+
maxAttempts: 5,
158+
retryMode: 'adaptive',
159+
});
160+
const parentCategory = parentLogicalId ? this.extractCategory(parentLogicalId) : undefined;
161+
162+
let nextToken: string | undefined;
163+
const nestedStackTasks: Array<{ physicalId: string; logicalId: string | undefined }> = [];
164+
165+
do {
166+
const response = await cfn.send(new ListStackResourcesCommand({ StackName: stackName, NextToken: nextToken }));
167+
nextToken = response.NextToken;
168+
169+
for (const resource of response.StackResourceSummaries ?? []) {
170+
if (resource.ResourceType === 'AWS::CloudFormation::Stack' && resource.PhysicalResourceId) {
171+
nestedStackTasks.push({
172+
physicalId: resource.PhysicalResourceId,
173+
logicalId: resource.LogicalResourceId,
174+
});
175+
} else if (resource.ResourceType && STATEFUL_RESOURCES.has(resource.ResourceType)) {
176+
const category = parentCategory || this.extractCategory(resource.LogicalResourceId || '');
177+
const physicalId = resource.PhysicalResourceId || 'N/A';
178+
if (this.spinner) {
179+
this.spinner.start(`Scanning '${category}' category: found stateful resource "${physicalId}"`);
180+
}
181+
statefulResources.push({
182+
category,
183+
resourceType: resource.ResourceType,
184+
physicalId,
185+
});
186+
}
130187
}
131-
}
188+
} while (nextToken);
189+
190+
const nestedResults = await Promise.all(
191+
nestedStackTasks.map((task) =>
192+
this.limiter.schedule(() => {
193+
const category = this.extractCategory(task.logicalId || '');
194+
return this.getStatefulResources(task.physicalId, category !== 'other' ? task.logicalId : parentLogicalId);
195+
}),
196+
),
197+
);
198+
199+
nestedResults.forEach((nested) => statefulResources.push(...nested));
132200
return statefulResources;
133201
}
202+
203+
private extractCategory(logicalId: string): string {
204+
const idLower = logicalId.toLowerCase();
205+
if (idLower.includes('auth')) return 'Auth';
206+
if (idLower.includes('storage')) return 'Storage';
207+
if (idLower.includes('function')) return 'Function';
208+
if (idLower.includes('api')) return 'Api';
209+
if (idLower.includes('analytics')) return 'Analytics';
210+
if (idLower.includes('hosting')) return 'Hosting';
211+
if (idLower.includes('notifications')) return 'Notifications';
212+
if (idLower.includes('interactions')) return 'Interactions';
213+
if (idLower.includes('predictions')) return 'Predictions';
214+
if (idLower.includes('deployment') || idLower.includes('infrastructure')) return 'Core Infrastructure';
215+
if (idLower.includes('geo')) return 'Geo';
216+
if (idLower.includes('custom')) return 'Custom';
217+
return 'other';
218+
}
134219
}

0 commit comments

Comments
 (0)