Skip to content

Commit b2214fc

Browse files
authored
Merge branch 'gen2-migration' into sai/validate-deployment-status-for-gen2-migration
2 parents a4425ad + d0f1037 commit b2214fc

5 files changed

Lines changed: 370 additions & 107 deletions

File tree

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,174 @@ describe('AmplifyGen2MigrationValidations', () => {
429429
name: 'StackStateError',
430430
message: 'Root stack status is ROLLBACK_COMPLETE, expected UPDATE_COMPLETE or CREATE_COMPLETE',
431431
resolution: 'Complete the deployment before proceeding.',
432+
describe('validateStatefulResources - nested stacks', () => {
433+
let mockSend: jest.Mock;
434+
435+
beforeEach(() => {
436+
mockSend = jest.fn();
437+
(CloudFormationClient as jest.Mock).mockImplementation(() => ({
438+
send: mockSend,
439+
}));
440+
});
441+
442+
afterEach(() => {
443+
jest.clearAllMocks();
444+
});
445+
446+
it('should throw when nested stack contains stateful resources', async () => {
447+
mockSend.mockResolvedValueOnce({
448+
StackResources: [
449+
{
450+
ResourceType: 'AWS::DynamoDB::Table',
451+
PhysicalResourceId: 'MyTable',
452+
LogicalResourceId: 'Table',
453+
},
454+
],
455+
});
456+
457+
const changeSet: DescribeChangeSetOutput = {
458+
Changes: [
459+
{
460+
Type: 'Resource',
461+
ResourceChange: {
462+
Action: 'Remove',
463+
ResourceType: 'AWS::CloudFormation::Stack',
464+
LogicalResourceId: 'AuthStack',
465+
PhysicalResourceId: 'auth-stack',
466+
},
467+
},
468+
],
469+
};
470+
471+
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
472+
name: 'DestructiveMigrationError',
473+
message:
474+
'Stateful resources scheduled for deletion: AuthStack (AWS::CloudFormation::Stack) containing: Table (AWS::DynamoDB::Table).',
475+
});
476+
});
477+
478+
it('should pass when nested stack contains only stateless resources', async () => {
479+
mockSend.mockResolvedValueOnce({
480+
StackResources: [
481+
{
482+
ResourceType: 'AWS::Lambda::Function',
483+
PhysicalResourceId: 'MyFunction',
484+
LogicalResourceId: 'Function',
485+
},
486+
],
487+
});
488+
489+
const changeSet: DescribeChangeSetOutput = {
490+
Changes: [
491+
{
492+
Type: 'Resource',
493+
ResourceChange: {
494+
Action: 'Remove',
495+
ResourceType: 'AWS::CloudFormation::Stack',
496+
LogicalResourceId: 'LambdaStack',
497+
PhysicalResourceId: 'lambda-stack',
498+
},
499+
},
500+
],
501+
};
502+
503+
await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow();
504+
});
505+
506+
it('should handle multiple levels of nested stacks', async () => {
507+
mockSend.mockResolvedValueOnce({
508+
StackResources: [
509+
{
510+
ResourceType: 'AWS::CloudFormation::Stack',
511+
PhysicalResourceId: 'storage-nested-stack',
512+
LogicalResourceId: 'StorageNestedStack',
513+
},
514+
],
515+
});
516+
517+
mockSend.mockResolvedValueOnce({
518+
StackResources: [
519+
{
520+
ResourceType: 'AWS::S3::Bucket',
521+
PhysicalResourceId: 'my-bucket',
522+
LogicalResourceId: 'Bucket',
523+
},
524+
],
525+
});
526+
527+
const changeSet: DescribeChangeSetOutput = {
528+
Changes: [
529+
{
530+
Type: 'Resource',
531+
ResourceChange: {
532+
Action: 'Remove',
533+
ResourceType: 'AWS::CloudFormation::Stack',
534+
LogicalResourceId: 'StorageStack',
535+
PhysicalResourceId: 'storage-stack',
536+
},
537+
},
538+
],
539+
};
540+
541+
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
542+
name: 'DestructiveMigrationError',
543+
});
544+
});
545+
546+
it('should pass when nested stack is missing PhysicalResourceId', async () => {
547+
const changeSet: DescribeChangeSetOutput = {
548+
Changes: [
549+
{
550+
Type: 'Resource',
551+
ResourceChange: {
552+
Action: 'Remove',
553+
ResourceType: 'AWS::CloudFormation::Stack',
554+
LogicalResourceId: 'IncompleteStack',
555+
PhysicalResourceId: undefined,
556+
},
557+
},
558+
],
559+
};
560+
561+
await expect(validations.validateStatefulResources(changeSet)).resolves.not.toThrow();
562+
});
563+
564+
it('should handle mixed direct and nested stateful resources', async () => {
565+
mockSend.mockResolvedValueOnce({
566+
StackResources: [
567+
{
568+
ResourceType: 'AWS::Cognito::UserPool',
569+
PhysicalResourceId: 'user-pool',
570+
LogicalResourceId: 'UserPool',
571+
},
572+
],
573+
});
574+
575+
const changeSet: DescribeChangeSetOutput = {
576+
Changes: [
577+
{
578+
Type: 'Resource',
579+
ResourceChange: {
580+
Action: 'Remove',
581+
ResourceType: 'AWS::DynamoDB::Table',
582+
LogicalResourceId: 'DirectTable',
583+
},
584+
},
585+
{
586+
Type: 'Resource',
587+
ResourceChange: {
588+
Action: 'Remove',
589+
ResourceType: 'AWS::CloudFormation::Stack',
590+
LogicalResourceId: 'AuthStack',
591+
PhysicalResourceId: 'auth-stack',
592+
},
593+
},
594+
],
595+
};
596+
597+
await expect(validations.validateStatefulResources(changeSet)).rejects.toMatchObject({
598+
name: 'DestructiveMigrationError',
599+
message: expect.stringContaining('DirectTable'),
432600
});
433601
});
434602
});

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

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { AmplifyDriftDetector } from '../drift';
22
import { $TSContext, AmplifyError, stateManager } from '@aws-amplify/amplify-cli-core';
33
import { printer } from '@aws-amplify/amplify-prompts';
4-
import { DescribeChangeSetOutput, CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation';
4+
import { DescribeChangeSetOutput, CloudFormationClient, DescribeStacksCommand, DescribeStackResourcesCommand } from '@aws-sdk/client-cloudformation';
55
import { STATEFUL_RESOURCES } from './stateful-resources';
66

77
export class AmplifyGen2MigrationValidations {
@@ -59,20 +59,27 @@ export class AmplifyGen2MigrationValidations {
5959
public async validateStatefulResources(changeSet: DescribeChangeSetOutput): Promise<void> {
6060
if (!changeSet.Changes) return;
6161

62-
const statefulRemoves = changeSet.Changes.filter(
63-
(change) =>
64-
change.Type === 'Resource' &&
65-
change.ResourceChange?.Action === 'Remove' &&
66-
change.ResourceChange?.ResourceType &&
67-
STATEFUL_RESOURCES.has(change.ResourceChange.ResourceType),
68-
);
62+
const statefulRemoves: string[] = [];
63+
for (const change of changeSet.Changes) {
64+
if (change.Type === 'Resource' && change.ResourceChange?.Action === 'Remove' && change.ResourceChange?.ResourceType) {
65+
if (change.ResourceChange.ResourceType === 'AWS::CloudFormation::Stack' && change.ResourceChange.PhysicalResourceId) {
66+
const nestedResources = await this.getStatefulResources(change.ResourceChange.PhysicalResourceId);
67+
if (nestedResources.length > 0) {
68+
statefulRemoves.push(
69+
`${change.ResourceChange.LogicalResourceId} (${change.ResourceChange.ResourceType}) containing: ${nestedResources.join(
70+
', ',
71+
)}`,
72+
);
73+
}
74+
} else if (STATEFUL_RESOURCES.has(change.ResourceChange.ResourceType)) {
75+
statefulRemoves.push(`${change.ResourceChange.LogicalResourceId} (${change.ResourceChange.ResourceType})`);
76+
}
77+
}
78+
}
6979

7080
if (statefulRemoves.length > 0) {
71-
const resources = statefulRemoves
72-
.map((c) => `${c.ResourceChange?.LogicalResourceId ?? 'Unknown'} (${c.ResourceChange?.ResourceType})`)
73-
.join(', ');
7481
throw new AmplifyError('DestructiveMigrationError', {
75-
message: `Stateful resources scheduled for deletion: ${resources}.`,
82+
message: `Stateful resources scheduled for deletion: ${statefulRemoves.join(', ')}.`,
7683
resolution: 'Review the migration plan and ensure data is backed up before proceeding.',
7784
});
7885
}
@@ -81,4 +88,20 @@ export class AmplifyGen2MigrationValidations {
8188
public async validateIngressTraffic(): Promise<void> {
8289
printer.warn('Not implemented');
8390
}
91+
92+
private async getStatefulResources(stackName: string): Promise<string[]> {
93+
const statefulResources: string[] = [];
94+
const cfn = new CloudFormationClient({});
95+
const { StackResources } = await cfn.send(new DescribeStackResourcesCommand({ StackName: stackName }));
96+
97+
for (const resource of StackResources ?? []) {
98+
if (resource.ResourceType === 'AWS::CloudFormation::Stack' && resource.PhysicalResourceId) {
99+
const nested = await this.getStatefulResources(resource.PhysicalResourceId);
100+
statefulResources.push(...nested);
101+
} else if (resource.ResourceType && STATEFUL_RESOURCES.has(resource.ResourceType)) {
102+
statefulResources.push(`${resource.LogicalResourceId} (${resource.ResourceType})`);
103+
}
104+
}
105+
return statefulResources;
106+
}
84107
}

0 commit comments

Comments
 (0)