diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations-template-drift.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations-template-drift.test.ts new file mode 100644 index 00000000000..750e547def1 --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_validations-template-drift.test.ts @@ -0,0 +1,125 @@ +import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_validations'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { Logger } from '../../../commands/gen2-migration'; + +jest.mock('@aws-sdk/client-cloudformation'); +jest.mock('bottleneck', () => { + return jest.fn().mockImplementation(() => ({ + schedule: jest.fn((fn) => fn()), + })); +}); + +const mockSyncCloudBackendFromS3 = jest.fn(); +const mockGetClient = jest.fn(); +jest.mock('../../../commands/drift-detection/services', () => ({ + CloudFormationService: jest.fn().mockImplementation(() => ({ + syncCloudBackendFromS3: mockSyncCloudBackendFromS3, + getClient: mockGetClient, + })), +})); + +const mockDetectTemplateDrift = jest.fn(); +jest.mock('../../../commands/drift-detection/detect-template-drift', () => ({ + detectTemplateDrift: (...args: any[]) => mockDetectTemplateDrift(...args), +})); + +describe('AmplifyGen2MigrationValidations - validateTemplateDrift', () => { + let mockContext: $TSContext; + let validations: AmplifyGen2MigrationValidations; + const mockCfnClient = {}; + + beforeEach(() => { + mockContext = {} as $TSContext; + validations = new AmplifyGen2MigrationValidations(new Logger('mock', 'mock', 'mock'), 'amplify-test-stack', 'dev', mockContext); + mockGetClient.mockResolvedValue(mockCfnClient); + jest.clearAllMocks(); + }); + + it('should pass when no template drift is detected', async () => { + mockSyncCloudBackendFromS3.mockResolvedValue(true); + mockDetectTemplateDrift.mockResolvedValue({ + changes: [], + skipped: false, + }); + + await expect(validations.validateTemplateDrift()).resolves.not.toThrow(); + expect(mockSyncCloudBackendFromS3).toHaveBeenCalledWith(mockContext); + expect(mockDetectTemplateDrift).toHaveBeenCalledWith('amplify-test-stack', expect.any(Object), mockCfnClient); + }); + + it('should throw MigrationError when template drift is detected', async () => { + mockSyncCloudBackendFromS3.mockResolvedValue(true); + mockDetectTemplateDrift.mockResolvedValue({ + changes: [{ LogicalResourceId: 'SomeResource', Action: 'Modify' }], + skipped: false, + }); + + await expect(validations.validateTemplateDrift()).rejects.toMatchObject({ + name: 'MigrationError', + message: 'Template drift detected', + }); + }); + + it('should throw MigrationError when S3 sync fails', async () => { + mockSyncCloudBackendFromS3.mockResolvedValue(false); + + await expect(validations.validateTemplateDrift()).rejects.toMatchObject({ + name: 'MigrationError', + message: 'Failed to sync cloud backend from S3', + resolution: 'Ensure the project is deployed and S3 bucket is accessible.', + }); + expect(mockDetectTemplateDrift).not.toHaveBeenCalled(); + }); + + it('should throw MigrationError when template drift detection is skipped', async () => { + mockSyncCloudBackendFromS3.mockResolvedValue(true); + mockDetectTemplateDrift.mockResolvedValue({ + changes: [], + skipped: true, + skipReason: 'No cached CloudFormation template found', + }); + + await expect(validations.validateTemplateDrift()).rejects.toMatchObject({ + name: 'MigrationError', + message: 'Template drift detection was skipped: No cached CloudFormation template found', + }); + }); + + it('should throw MigrationError when detection is skipped due to nested stack errors', async () => { + mockSyncCloudBackendFromS3.mockResolvedValue(true); + mockDetectTemplateDrift.mockResolvedValue({ + changes: [], + skipped: true, + skipReason: 'One or more nested stacks could not be analyzed', + }); + + await expect(validations.validateTemplateDrift()).rejects.toMatchObject({ + name: 'MigrationError', + message: 'Template drift detection was skipped: One or more nested stacks could not be analyzed', + }); + }); + + it('should pass the correct stack name and CFN client to detectTemplateDrift', async () => { + mockSyncCloudBackendFromS3.mockResolvedValue(true); + mockDetectTemplateDrift.mockResolvedValue({ + changes: [], + skipped: false, + }); + + await validations.validateTemplateDrift(); + + expect(mockGetClient).toHaveBeenCalledWith(mockContext); + expect(mockDetectTemplateDrift).toHaveBeenCalledWith( + 'amplify-test-stack', + expect.objectContaining({ + info: expect.any(Function), + debug: expect.any(Function), + warn: expect.any(Function), + blankLine: expect.any(Function), + success: expect.any(Function), + error: expect.any(Function), + }), + mockCfnClient, + ); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts new file mode 100644 index 00000000000..4bc705dfe5f --- /dev/null +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts @@ -0,0 +1,100 @@ +import { AmplifyMigrationLockStep } from '../../../commands/gen2-migration/lock'; +import { $TSContext } from '@aws-amplify/amplify-cli-core'; +import { Logger } from '../../../commands/gen2-migration'; + +const mockValidateDeploymentStatus = jest.fn(); +const mockValidateLockStatus = jest.fn(); +const mockValidateTemplateDrift = jest.fn(); + +jest.mock('../../../commands/gen2-migration/_validations', () => ({ + AmplifyGen2MigrationValidations: jest.fn().mockImplementation(() => ({ + validateDeploymentStatus: mockValidateDeploymentStatus, + validateLockStatus: mockValidateLockStatus, + validateTemplateDrift: mockValidateTemplateDrift, + validateDrift: jest.fn(), + })), +})); + +jest.mock('@aws-sdk/client-cloudformation'); +jest.mock('@aws-sdk/client-amplify'); +jest.mock('@aws-sdk/client-dynamodb'); +jest.mock('@aws-sdk/client-appsync'); +jest.mock('@aws-sdk/client-cognito-identity-provider'); +jest.mock('@aws-amplify/amplify-cli-core', () => ({ + ...jest.requireActual('@aws-amplify/amplify-cli-core'), + stateManager: { + getMeta: jest.fn().mockReturnValue({}), + }, +})); + +describe('AmplifyMigrationLockStep - rollbackValidate', () => { + let step: AmplifyMigrationLockStep; + let mockContext: $TSContext; + + beforeEach(() => { + mockContext = {} as $TSContext; + step = new AmplifyMigrationLockStep( + new Logger('lock', 'test-app', 'dev'), + 'dev', + 'test-app', + 'test-app-id', + 'amplify-test-stack', + 'us-east-1', + mockContext, + ); + jest.clearAllMocks(); + }); + + it('should call all three validations in order', async () => { + mockValidateDeploymentStatus.mockResolvedValue(undefined); + mockValidateLockStatus.mockResolvedValue(undefined); + mockValidateTemplateDrift.mockResolvedValue(undefined); + + await step.rollbackValidate(); + + expect(mockValidateDeploymentStatus).toHaveBeenCalledTimes(1); + expect(mockValidateLockStatus).toHaveBeenCalledTimes(1); + expect(mockValidateTemplateDrift).toHaveBeenCalledTimes(1); + }); + + it('should propagate error when validateDeploymentStatus fails', async () => { + const error = new Error('Stack not found'); + error.name = 'StackNotFoundError'; + mockValidateDeploymentStatus.mockRejectedValue(error); + + await expect(step.rollbackValidate()).rejects.toThrow('Stack not found'); + expect(mockValidateLockStatus).not.toHaveBeenCalled(); + expect(mockValidateTemplateDrift).not.toHaveBeenCalled(); + }); + + it('should propagate error when validateLockStatus fails', async () => { + mockValidateDeploymentStatus.mockResolvedValue(undefined); + const error = new Error('Stack is not locked'); + error.name = 'MigrationError'; + mockValidateLockStatus.mockRejectedValue(error); + + await expect(step.rollbackValidate()).rejects.toThrow('Stack is not locked'); + expect(mockValidateDeploymentStatus).toHaveBeenCalledTimes(1); + expect(mockValidateTemplateDrift).not.toHaveBeenCalled(); + }); + + it('should propagate error when validateTemplateDrift fails', async () => { + mockValidateDeploymentStatus.mockResolvedValue(undefined); + mockValidateLockStatus.mockResolvedValue(undefined); + const error = new Error('Template drift detected'); + error.name = 'MigrationError'; + mockValidateTemplateDrift.mockRejectedValue(error); + + await expect(step.rollbackValidate()).rejects.toThrow('Template drift detected'); + expect(mockValidateDeploymentStatus).toHaveBeenCalledTimes(1); + expect(mockValidateLockStatus).toHaveBeenCalledTimes(1); + }); + + it('should pass when all validations succeed with no drift', async () => { + mockValidateDeploymentStatus.mockResolvedValue(undefined); + mockValidateLockStatus.mockResolvedValue(undefined); + mockValidateTemplateDrift.mockResolvedValue(undefined); + + await expect(step.rollbackValidate()).resolves.not.toThrow(); + }); +}); diff --git a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts index 30c047ca8ce..9b59c08896f 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_validations.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_validations.ts @@ -15,6 +15,8 @@ import { Logger } from '../gen2-migration'; import chalk from 'chalk'; import { printer } from '@aws-amplify/amplify-prompts'; import { extractCategory } from './categories'; +import { detectTemplateDrift } from '../drift-detection/detect-template-drift'; +import { CloudFormationService } from '../drift-detection/services'; export class AmplifyGen2MigrationValidations { private limiter = new Bottleneck({ @@ -192,6 +194,39 @@ export class AmplifyGen2MigrationValidations { this.logger.info(chalk.green(`Stack ${this.rootStackName} is locked ✔`)); } + public async validateTemplateDrift(): Promise { + this.logger.info('Checking for template drift...'); + + const cfnService = new CloudFormationService(printer); + const syncSuccess = await cfnService.syncCloudBackendFromS3(this.context); + if (!syncSuccess) { + throw new AmplifyError('MigrationError', { + message: 'Failed to sync cloud backend from S3', + resolution: 'Ensure the project is deployed and S3 bucket is accessible.', + }); + } + + const cfn = await cfnService.getClient(this.context); + const results = await detectTemplateDrift(this.rootStackName, printer, cfn); + + if (results.skipped) { + throw new AmplifyError('MigrationError', { + message: `Template drift detection was skipped: ${results.skipReason}`, + resolution: 'Ensure the project is deployed and templates are available.', + }); + } + + if (results.changes.length > 0) { + throw new AmplifyError('MigrationError', { + message: 'Template drift detected', + resolution: + 'The CloudFormation templates have changed since the lock was applied. This may indicate that a refactor has already been performed. Resolve the drift before rolling back.', + }); + } + + this.logger.info(chalk.green('No template drift detected ✔')); + } + private async getStatefulResources( stackName: string, parentLogicalId?: string, diff --git a/packages/amplify-cli/src/commands/gen2-migration/lock.ts b/packages/amplify-cli/src/commands/gen2-migration/lock.ts index 8a282ac2bb1..b771b51e727 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/lock.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/lock.ts @@ -40,8 +40,10 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { } public async rollbackValidate(): Promise { - // https://github.com/aws-amplify/amplify-cli/issues/14570 - return; + const validations = new AmplifyGen2MigrationValidations(this.logger, this.rootStackName, this.currentEnvName, this.context); + await validations.validateDeploymentStatus(); + await validations.validateLockStatus(); + await validations.validateTemplateDrift(); } public async execute(): Promise {