diff --git a/amplify-migration-apps/README.md b/amplify-migration-apps/README.md index 7baccd63021..c8ea9c4baee 100644 --- a/amplify-migration-apps/README.md +++ b/amplify-migration-apps/README.md @@ -27,7 +27,8 @@ Each app directory follows this layout: │ ├── config.json # E2E system configuration (optional) │ ├── post-generate.ts # Fixups after gen2-migration generate │ ├── post-push.ts # Fixups after amplify push (optional) -│ └── post-refactor.ts # Fixups after gen2-migration refactor +│ ├── post-refactor.ts # Fixups after gen2-migration refactor +│ └── post-rollback.ts # Reverses post-refactor fixups after rollback (optional) ├── tests/ # Jest test suites for validating deployed stacks │ ├── signup.ts # Cognito user provisioning (app-specific) │ ├── jest.setup.ts # Jest setup (retry config) @@ -60,17 +61,34 @@ caller's working directory. ### `migration/config.json` Configuration file read by the [E2E system](../packages/amplify-e2e-gen2-migration/) at runtime. -Currently supports: +Each key corresponds to a migration step and accepts a `StepConfig` object: ```json { - "lock": { "skipValidations": true } + "lockForward": { "skipValidations": true }, + "lockRollback": { "skipValidations": false }, + "refactorForward": { "skip": true }, + "refactorRollback": { "skipValidations": true }, + "generate": { "skipValidations": true } } ``` -- `lock.skipValidations` — pass `--skip-validations` to `gen2-migration lock`. +| Field | Type | Description | +| ------------------ | ------------ | ------------------------------------------------------------ | +| `lockForward` | `StepConfig` | Config for `gen2-migration lock`. | +| `lockRollback` | `StepConfig` | Config for `gen2-migration lock --rollback`. | +| `refactorForward` | `StepConfig` | Config for `gen2-migration refactor`. | +| `refactorRollback` | `StepConfig` | Config for `gen2-migration refactor --rollback`. | +| `generate` | `StepConfig` | Config for `gen2-migration generate`. | -If the file does not exist, defaults are used (no skip-validations). +`StepConfig` fields: + +| Field | Type | Description | +| ------------------ | --------- | -------------------------------------------------- | +| `skipValidations` | `boolean` | Pass `--skip-validations` to the step. | +| `skip` | `boolean` | Skip the step entirely. | + +If the file does not exist, defaults are used (no skips, no skip-validations). ### `tests/` @@ -104,6 +122,18 @@ If a script does not exist for an app, the E2E system silently skips the step. > Some apps don't have `_snapshot.post.refactor/` because refactor doesn't work > for them yet. +### `migration/post-rollback.ts` + +Optional script that reverses the fixups applied by `post-refactor.ts` after a +`gen2-migration refactor --rollback`. For example, if `post-refactor.ts` uncomments +a function call in `amplify/backend.ts`, `post-rollback.ts` comments it back. + +```typescript +export async function postRollback(appPath: string): Promise; +``` + +If a script does not exist for an app, the E2E system silently skips the step. + ### `migration/pre-push.ts` and `migration/post-sandbox.ts` Optional scripts for additional lifecycle hooks: diff --git a/amplify-migration-apps/backend-only/migration/post-rollback.ts b/amplify-migration-apps/backend-only/migration/post-rollback.ts new file mode 100644 index 00000000000..6fc7a09686c --- /dev/null +++ b/amplify-migration-apps/backend-only/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for backend-only app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/backend-only/package.json b/amplify-migration-apps/backend-only/package.json index 7c48f5d4ff5..91f8f269d8c 100644 --- a/amplify-migration-apps/backend-only/package.json +++ b/amplify-migration-apps/backend-only/package.json @@ -19,6 +19,7 @@ "pre-push": "true", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/discussions/migration/post-rollback.ts b/amplify-migration-apps/discussions/migration/post-rollback.ts new file mode 100644 index 00000000000..fd4641a8c92 --- /dev/null +++ b/amplify-migration-apps/discussions/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for discussions app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/discussions/package.json b/amplify-migration-apps/discussions/package.json index 51711588973..e4265251805 100644 --- a/amplify-migration-apps/discussions/package.json +++ b/amplify-migration-apps/discussions/package.json @@ -24,6 +24,7 @@ "pre-push": "true", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/discussions/tests/api.test.ts b/amplify-migration-apps/discussions/tests/api.test.ts index b71f5a2fc13..b72e0364df6 100644 --- a/amplify-migration-apps/discussions/tests/api.test.ts +++ b/amplify-migration-apps/discussions/tests/api.test.ts @@ -116,7 +116,7 @@ describe('Topic', () => { }); const created = (createResult as any).data.createTopic; - const listResult = await client().graphql({ query: listTopics }); + const listResult = await client().graphql({ query: listTopics, variables: { limit: 1000 } }); const items = (listResult as any).data.listTopics.items; expect(Array.isArray(items)).toBe(true); @@ -226,7 +226,7 @@ describe('Post', () => { }); const created = (createResult as any).data.createPost; - const listResult = await client().graphql({ query: listPosts }); + const listResult = await client().graphql({ query: listPosts, variables: { limit: 1000 } }); const items = (listResult as any).data.listPosts.items; expect(Array.isArray(items)).toBe(true); @@ -342,7 +342,7 @@ describe('Comment', () => { }); const created = (createResult as any).data.createComment; - const listResult = await client().graphql({ query: listComments }); + const listResult = await client().graphql({ query: listComments, variables: { limit: 1000 } }); const items = (listResult as any).data.listComments.items; expect(Array.isArray(items)).toBe(true); diff --git a/amplify-migration-apps/finance-tracker/_snapshot.post.generate/amplify/custom/customresolver/construct.ts b/amplify-migration-apps/finance-tracker/_snapshot.post.generate/amplify/custom/customresolver/construct.ts index f92b201d907..a51c91bcf3b 100644 --- a/amplify-migration-apps/finance-tracker/_snapshot.post.generate/amplify/custom/customresolver/construct.ts +++ b/amplify-migration-apps/finance-tracker/_snapshot.post.generate/amplify/custom/customresolver/construct.ts @@ -54,7 +54,8 @@ export class Customresolver extends Construct { ":category": $util.dynamodb.toDynamoDBJson($ctx.args.category) } }, - "limit": $limit + "limit": $limit, + "consistentRead": true }`; // Response mapping template const responseTemplate = ` diff --git a/amplify-migration-apps/finance-tracker/_snapshot.pre.generate/amplify/backend/custom/customresolver/cdk-stack.ts b/amplify-migration-apps/finance-tracker/_snapshot.pre.generate/amplify/backend/custom/customresolver/cdk-stack.ts index 64d547c8a95..c61734a1af0 100644 --- a/amplify-migration-apps/finance-tracker/_snapshot.pre.generate/amplify/backend/custom/customresolver/cdk-stack.ts +++ b/amplify-migration-apps/finance-tracker/_snapshot.pre.generate/amplify/backend/custom/customresolver/cdk-stack.ts @@ -74,7 +74,8 @@ export class cdkStack extends cdk.Stack { ":category": $util.dynamodb.toDynamoDBJson($ctx.args.category) } }, - "limit": $limit + "limit": $limit, + "consistentRead": true }`; // Response mapping template diff --git a/amplify-migration-apps/finance-tracker/backend/customresolver.ts b/amplify-migration-apps/finance-tracker/backend/customresolver.ts index 64d547c8a95..c61734a1af0 100644 --- a/amplify-migration-apps/finance-tracker/backend/customresolver.ts +++ b/amplify-migration-apps/finance-tracker/backend/customresolver.ts @@ -74,7 +74,8 @@ export class cdkStack extends cdk.Stack { ":category": $util.dynamodb.toDynamoDBJson($ctx.args.category) } }, - "limit": $limit + "limit": $limit, + "consistentRead": true }`; // Response mapping template diff --git a/amplify-migration-apps/finance-tracker/migration/config.json b/amplify-migration-apps/finance-tracker/migration/config.json index cfcd0b25339..2ecd23aa171 100644 --- a/amplify-migration-apps/finance-tracker/migration/config.json +++ b/amplify-migration-apps/finance-tracker/migration/config.json @@ -1,4 +1,3 @@ { - "refactor": { "skip": true }, "generate": { "skipValidations": true } } diff --git a/amplify-migration-apps/finance-tracker/package.json b/amplify-migration-apps/finance-tracker/package.json index d8313dc092b..094d9507e80 100644 --- a/amplify-migration-apps/finance-tracker/package.json +++ b/amplify-migration-apps/finance-tracker/package.json @@ -15,12 +15,13 @@ "pre-push": "npx tsx migration/pre-push.ts", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "true", + "post-rollback": "true", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true", "test:gen1": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-src/amplifyconfiguration.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", "test:gen2": "APP_CONFIG_PATH=${APP_CONFIG_PATH:-amplify_outputs.json} NODE_OPTIONS='--experimental-vm-modules' jest --verbose", - "test:shared-data": "true", + "test:shared": "true", "test:e2e": "cd ../../packages/amplify-e2e-gen2-migration && npx tsx src/cli.ts --app finance-tracker --profile ${AWS_PROFILE:-default}", "deploy": "cd ../../packages/amplify-e2e-gen2-migration && npx tsx src/cli.ts --app finance-tracker --step deploy --profile ${AWS_PROFILE:-default}" }, diff --git a/amplify-migration-apps/finance-tracker/tests/api.test.ts b/amplify-migration-apps/finance-tracker/tests/api.test.ts index 4000b2f5802..31ecdcdff36 100644 --- a/amplify-migration-apps/finance-tracker/tests/api.test.ts +++ b/amplify-migration-apps/finance-tracker/tests/api.test.ts @@ -28,25 +28,37 @@ afterAll(async () => { await signOut(); }); -describe('Transaction', () => { - let transactionId: string; +async function createTestTransaction(overrides: Record = {}) { + const input = { + description: `Test transaction - ${Date.now()}`, + amount: 85.50, + type: TransactionType.EXPENSE, + category: 'Food', + date: new Date().toISOString(), + ...overrides, + }; + const result = await authClient().graphql({ query: createTransaction, variables: { input } }); + return (result as any).data.createTransaction; +} + +async function createTestSummary(overrides: Record = {}) { + const input = { + totalIncome: 5000.00, + totalExpenses: 3200.00, + balance: 1800.00, + month: '2026-04', + ...overrides, + }; + const result = await authClient().graphql({ query: createFinancialSummary, variables: { input } }); + return (result as any).data.createFinancialSummary; +} +describe('Transaction', () => { it('creates a transaction with correct fields', async () => { - const input = { - description: `Grocery shopping - ${Date.now()}`, - amount: 85.50, - type: TransactionType.EXPENSE, - category: 'Food', - date: new Date().toISOString(), - }; - - const result = await authClient().graphql({ query: createTransaction, variables: { input } }); - const txn = (result as any).data.createTransaction; - transactionId = txn.id; + const txn = await createTestTransaction({ description: `Grocery shopping - ${Date.now()}` }); expect(typeof txn.id).toBe('string'); expect(txn.id.length).toBeGreaterThan(0); - expect(txn.description).toBe(input.description); expect(txn.amount).toBe(85.50); expect(txn.type).toBe(TransactionType.EXPENSE); expect(txn.category).toBe('Food'); @@ -55,29 +67,35 @@ describe('Transaction', () => { }); it('reads a transaction by id', async () => { - const result = await publicClient().graphql({ query: getTransaction, variables: { id: transactionId } }); - const txn = (result as any).data.getTransaction; + const txn = await createTestTransaction(); - expect(txn).not.toBeNull(); - expect(txn.id).toBe(transactionId); - expect(txn.category).toBe('Food'); + const result = await publicClient().graphql({ query: getTransaction, variables: { id: txn.id } }); + const fetched = (result as any).data.getTransaction; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(txn.id); + expect(fetched.category).toBe('Food'); }); it('updates a transaction and persists changes', async () => { + const txn = await createTestTransaction(); const updatedDesc = `Updated grocery - ${Date.now()}`; + await authClient().graphql({ query: updateTransaction, - variables: { input: { id: transactionId, description: updatedDesc, amount: 92.00 } }, + variables: { input: { id: txn.id, description: updatedDesc, amount: 92.00 } }, }); - const result = await publicClient().graphql({ query: getTransaction, variables: { id: transactionId } }); - const txn = (result as any).data.getTransaction; + const result = await publicClient().graphql({ query: getTransaction, variables: { id: txn.id } }); + const fetched = (result as any).data.getTransaction; - expect(txn.description).toBe(updatedDesc); - expect(txn.amount).toBe(92.00); + expect(fetched.description).toBe(updatedDesc); + expect(fetched.amount).toBe(92.00); }); it('lists transactions', async () => { + await createTestTransaction(); + const result = await publicClient().graphql({ query: listTransactions }); const items = (result as any).data.listTransactions.items; @@ -86,27 +104,18 @@ describe('Transaction', () => { }); it('deletes a transaction', async () => { - await authClient().graphql({ query: deleteTransaction, variables: { input: { id: transactionId } } }); + const txn = await createTestTransaction(); - const result = await publicClient().graphql({ query: getTransaction, variables: { id: transactionId } }); + await authClient().graphql({ query: deleteTransaction, variables: { input: { id: txn.id } } }); + + const result = await publicClient().graphql({ query: getTransaction, variables: { id: txn.id } }); expect((result as any).data.getTransaction).toBeNull(); }); }); describe('FinancialSummary', () => { - let summaryId: string; - it('creates a financial summary', async () => { - const input = { - totalIncome: 5000.00, - totalExpenses: 3200.00, - balance: 1800.00, - month: '2026-04', - }; - - const result = await authClient().graphql({ query: createFinancialSummary, variables: { input } }); - const summary = (result as any).data.createFinancialSummary; - summaryId = summary.id; + const summary = await createTestSummary(); expect(typeof summary.id).toBe('string'); expect(summary.totalIncome).toBe(5000.00); @@ -117,15 +126,19 @@ describe('FinancialSummary', () => { }); it('reads a financial summary by id', async () => { - const result = await publicClient().graphql({ query: getFinancialSummary, variables: { id: summaryId } }); - const summary = (result as any).data.getFinancialSummary; + const summary = await createTestSummary(); - expect(summary).not.toBeNull(); - expect(summary.id).toBe(summaryId); - expect(summary.month).toBe('2026-04'); + const result = await publicClient().graphql({ query: getFinancialSummary, variables: { id: summary.id } }); + const fetched = (result as any).data.getFinancialSummary; + + expect(fetched).not.toBeNull(); + expect(fetched.id).toBe(summary.id); + expect(fetched.month).toBe('2026-04'); }); it('lists financial summaries', async () => { + await createTestSummary(); + const result = await publicClient().graphql({ query: listFinancialSummaries }); const items = (result as any).data.listFinancialSummaries.items; @@ -134,26 +147,22 @@ describe('FinancialSummary', () => { }); it('deletes a financial summary', async () => { - await authClient().graphql({ query: deleteFinancialSummary, variables: { input: { id: summaryId } } }); + const summary = await createTestSummary(); + + await authClient().graphql({ query: deleteFinancialSummary, variables: { input: { id: summary.id } } }); - const result = await publicClient().graphql({ query: getFinancialSummary, variables: { id: summaryId } }); + const result = await publicClient().graphql({ query: getFinancialSummary, variables: { id: summary.id } }); expect((result as any).data.getFinancialSummary).toBeNull(); }); }); describe('Lambda-backed operations', () => { it('calculateFinancialSummary returns numeric fields', async () => { - await authClient().graphql({ - query: createTransaction, - variables: { - input: { - description: `Summary test income - ${Date.now()}`, - amount: 1000.00, - type: TransactionType.INCOME, - category: 'Salary', - date: new Date().toISOString(), - }, - }, + await createTestTransaction({ + description: `Summary test income - ${Date.now()}`, + amount: 1000.00, + type: TransactionType.INCOME, + category: 'Salary', }); const result = await publicClient().graphql({ query: calculateFinancialSummary }); @@ -198,22 +207,15 @@ describe('Custom VTL resolver', () => { it('getTransactionsByCategory returns filtered transactions', async () => { const category = `TestCategory-${Date.now()}`; - await authClient().graphql({ - query: createTransaction, - variables: { - input: { - description: `Category filter test - ${Date.now()}`, - amount: 42.00, - type: TransactionType.EXPENSE, - category, - date: new Date().toISOString(), - }, - }, + await createTestTransaction({ + description: `Category filter test - ${Date.now()}`, + amount: 42.00, + category, }); const result = await publicClient().graphql({ query: getTransactionsByCategory, - variables: { category, limit: 10 }, + variables: { category, limit: 1000 }, }); const connection = (result as any).data.getTransactionsByCategory; diff --git a/amplify-migration-apps/fitness-tracker/migration/config.json b/amplify-migration-apps/fitness-tracker/migration/config.json index 6f1f19579b1..c81ed5851a9 100644 --- a/amplify-migration-apps/fitness-tracker/migration/config.json +++ b/amplify-migration-apps/fitness-tracker/migration/config.json @@ -1,5 +1,5 @@ { - "lock": { + "lockForward": { "skipValidations": true } } diff --git a/amplify-migration-apps/fitness-tracker/migration/post-rollback.ts b/amplify-migration-apps/fitness-tracker/migration/post-rollback.ts new file mode 100644 index 00000000000..a58b359fab3 --- /dev/null +++ b/amplify-migration-apps/fitness-tracker/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for fitness-tracker app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/fitness-tracker/package.json b/amplify-migration-apps/fitness-tracker/package.json index d7bdc2eaf91..eeb1b1a6944 100644 --- a/amplify-migration-apps/fitness-tracker/package.json +++ b/amplify-migration-apps/fitness-tracker/package.json @@ -27,6 +27,7 @@ "pre-push": "npx tsx migration/pre-push.ts", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/imported-resources/migration/post-rollback.ts b/amplify-migration-apps/imported-resources/migration/post-rollback.ts new file mode 100644 index 00000000000..47d8ac41980 --- /dev/null +++ b/amplify-migration-apps/imported-resources/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for imported-resources app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/imported-resources/package.json b/amplify-migration-apps/imported-resources/package.json index a9f013a3acf..78d5fc881f0 100644 --- a/amplify-migration-apps/imported-resources/package.json +++ b/amplify-migration-apps/imported-resources/package.json @@ -25,6 +25,7 @@ "pre-push": "true", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/imported-resources/tests/api.test.ts b/amplify-migration-apps/imported-resources/tests/api.test.ts index 59c6ea7f0bd..e1c86392abb 100644 --- a/amplify-migration-apps/imported-resources/tests/api.test.ts +++ b/amplify-migration-apps/imported-resources/tests/api.test.ts @@ -195,7 +195,7 @@ describe('auth', () => { }); const created = (createResult as any).data.createProject; - const listResult = await auth().graphql({ query: listProjects }); + const listResult = await auth().graphql({ query: listProjects, variables: { limit: 1000 } }); const items = (listResult as any).data.listProjects.items; expect(Array.isArray(items)).toBe(true); @@ -293,7 +293,7 @@ describe('auth', () => { }); const created = (createResult as any).data.createTodo; - const listResult = await auth().graphql({ query: listTodos }); + const listResult = await auth().graphql({ query: listTodos, variables: { limit: 1000 } }); const items = (listResult as any).data.listTodos.items; expect(Array.isArray(items)).toBe(true); diff --git a/amplify-migration-apps/media-vault/migration/config.json b/amplify-migration-apps/media-vault/migration/config.json deleted file mode 100644 index 0967ef424bc..00000000000 --- a/amplify-migration-apps/media-vault/migration/config.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/amplify-migration-apps/media-vault/migration/post-rollback.ts b/amplify-migration-apps/media-vault/migration/post-rollback.ts new file mode 100644 index 00000000000..c4cae7006f2 --- /dev/null +++ b/amplify-migration-apps/media-vault/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for media-vault app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/media-vault/package.json b/amplify-migration-apps/media-vault/package.json index d13d28bb628..97ce6145612 100644 --- a/amplify-migration-apps/media-vault/package.json +++ b/amplify-migration-apps/media-vault/package.json @@ -25,6 +25,7 @@ "post-generate": "npx tsx migration/post-generate.ts", "post-generate-local": "SKIP_AUTH_SECRET_PATCH=1 npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/media-vault/tests/api.test.ts b/amplify-migration-apps/media-vault/tests/api.test.ts index b52e9495cc6..d9d5075cb5a 100644 --- a/amplify-migration-apps/media-vault/tests/api.test.ts +++ b/amplify-migration-apps/media-vault/tests/api.test.ts @@ -168,7 +168,7 @@ describe('auth', () => { }); const created = (createResult as any).data.createNote; - const listResult = await auth().graphql({ query: listNotes }); + const listResult = await auth().graphql({ query: listNotes, variables: { limit: 1000 } }); const items = (listResult as any).data.listNotes.items; expect(Array.isArray(items)).toBe(true); diff --git a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/resolvers/Query.listBoards.res.vtl b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/resolvers/Query.listBoards.res.vtl index 7d06b986744..444659384ef 100644 --- a/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/resolvers/Query.listBoards.res.vtl +++ b/amplify-migration-apps/mood-board/_snapshot.pre.generate/amplify/backend/api/moodboard/resolvers/Query.listBoards.res.vtl @@ -5,8 +5,8 @@ ## Add a pin emoji prefix to each board name so it's visible on the frontend #foreach( $item in $ctx.result.items ) - #if( !$item.name.startsWith("📌 ") ) - $util.qr($item.put("name", "📌 $item.name")) + #if( !$item.name.startsWith("(new!)") ) + $util.qr($item.put("name", "(new!) $item.name")) #end #end diff --git a/amplify-migration-apps/mood-board/migration/post-rollback.ts b/amplify-migration-apps/mood-board/migration/post-rollback.ts new file mode 100644 index 00000000000..4feeab7e94f --- /dev/null +++ b/amplify-migration-apps/mood-board/migration/post-rollback.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for mood-board app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + * 2. Revert SurpriseMeButton stream name to the Gen2 name + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +async function revertSurpriseMeStreamName(appPath: string, envName: string): Promise { + const constantsPath = path.join(appPath, 'src', 'constants.ts'); + const content = await fs.readFile(constantsPath, 'utf-8'); + + const gen2StreamName = `moodboardKinesis-gen2-${envName}`; + const updated = content.replace( + /export const KINESIS_STREAM_NAME\s*=\s*['"][^'"]+['"]/, + `export const KINESIS_STREAM_NAME = '${gen2StreamName}'`, + ); + + await fs.writeFile(constantsPath, updated, 'utf-8'); +} + +export async function postRollback(appPath: string, envName: string): Promise { + await commentPostRefactorCall(appPath); + await revertSurpriseMeStreamName(appPath, envName); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + if (!process.env.GEN1_ENV_NAME) { + throw new Error(`Missing GEN1_ENV_NAME env variable`); + } + await postRollback(appPath, process.env.GEN1_ENV_NAME); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/mood-board/package.json b/amplify-migration-apps/mood-board/package.json index ede49d73bc0..74872452266 100644 --- a/amplify-migration-apps/mood-board/package.json +++ b/amplify-migration-apps/mood-board/package.json @@ -24,6 +24,7 @@ "pre-push": "true", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "npx tsx migration/post-push.ts" diff --git a/amplify-migration-apps/mood-board/tests/api.test.ts b/amplify-migration-apps/mood-board/tests/api.test.ts index 9545bb41ade..1ede5d9679f 100644 --- a/amplify-migration-apps/mood-board/tests/api.test.ts +++ b/amplify-migration-apps/mood-board/tests/api.test.ts @@ -38,7 +38,7 @@ describe('guest', () => { expect(typeof board.id).toBe('string'); expect(board.id.length).toBeGreaterThan(0); - expect(board.name).toMatch(new RegExp(`^[🌅☀️🌙] ${name} \\(new!\\)$`)); + expect(board.name).toEqual(`${name} (new!)`); expect(board.createdAt).toBeDefined(); expect(board.updatedAt).toBeDefined(); }); @@ -99,14 +99,14 @@ describe('guest', () => { }); const created = (createResult as any).data.createBoard; - const listResult = await guest().graphql({ query: listBoards }); + const listResult = await guest().graphql({ query: listBoards, variables: { limit: 1000 } }); const items = (listResult as any).data.listBoards.items; expect(Array.isArray(items)).toBe(true); expect(items.length).toBeGreaterThanOrEqual(1); const found = items.find((b: any) => b.id === created.id); expect(found).toBeDefined(); - expect(found.name).toBe(`📌 ${name}`); + expect(found.name).toBe(`(new!) ${name}`); }); }); @@ -204,7 +204,7 @@ describe('guest', () => { }); const created = (createResult as any).data.createMoodItem; - const listResult = await guest().graphql({ query: listMoodItems }); + const listResult = await guest().graphql({ query: listMoodItems, variables: { limit: 1000 } }); const items = (listResult as any).data.listMoodItems.items; expect(Array.isArray(items)).toBe(true); diff --git a/amplify-migration-apps/product-catalog/migration/post-rollback.ts b/amplify-migration-apps/product-catalog/migration/post-rollback.ts new file mode 100644 index 00000000000..683612b46ef --- /dev/null +++ b/amplify-migration-apps/product-catalog/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for product-catalog app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/product-catalog/package.json b/amplify-migration-apps/product-catalog/package.json index 380ee6e33e8..3deeabc7d1f 100644 --- a/amplify-migration-apps/product-catalog/package.json +++ b/amplify-migration-apps/product-catalog/package.json @@ -25,6 +25,7 @@ "pre-push": "npx tsx migration/pre-push.ts", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "npx tsx migration/post-sandbox.ts", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/product-catalog/tests/api.test.ts b/amplify-migration-apps/product-catalog/tests/api.test.ts index 47c6cad30c8..1a5a45cb613 100644 --- a/amplify-migration-apps/product-catalog/tests/api.test.ts +++ b/amplify-migration-apps/product-catalog/tests/api.test.ts @@ -146,7 +146,7 @@ describe('iam', () => { }); const created = (createResult as any).data.createProduct; - const result = await iam().graphql({ query: listProducts }); + const result = await iam().graphql({ query: listProducts, variables: { limit: 1000 } }); const items = (result as any).data.listProducts.items; expect(Array.isArray(items)).toBe(true); @@ -219,7 +219,7 @@ describe('iam', () => { variables: { input: { id: listUserId, email: `list${Date.now()}@example.com`, name: 'List User', role: UserRole.VIEWER } }, }); - const result = await iam().graphql({ query: listUsers }); + const result = await iam().graphql({ query: listUsers, variables: { limit: 1000 } }); const items = (result as any).data.listUsers.items; expect(Array.isArray(items)).toBe(true); @@ -331,7 +331,7 @@ describe('iam', () => { }); const created = (createResult as any).data.createComment; - const result = await iam().graphql({ query: listComments }); + const result = await iam().graphql({ query: listComments, variables: { limit: 1000 } }); const items = (result as any).data.listComments.items; expect(Array.isArray(items)).toBe(true); diff --git a/amplify-migration-apps/project-boards/migration/post-rollback.ts b/amplify-migration-apps/project-boards/migration/post-rollback.ts new file mode 100644 index 00000000000..bc2cb72236d --- /dev/null +++ b/amplify-migration-apps/project-boards/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for project-boards app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/project-boards/package.json b/amplify-migration-apps/project-boards/package.json index 3a46ee5c633..721db92d0e5 100644 --- a/amplify-migration-apps/project-boards/package.json +++ b/amplify-migration-apps/project-boards/package.json @@ -25,6 +25,7 @@ "pre-push": "true", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/project-boards/tests/api.test.ts b/amplify-migration-apps/project-boards/tests/api.test.ts index 0819ee25ff4..aeb5611c6de 100644 --- a/amplify-migration-apps/project-boards/tests/api.test.ts +++ b/amplify-migration-apps/project-boards/tests/api.test.ts @@ -196,7 +196,7 @@ describe('auth', () => { }); const created = (createResult as any).data.createProject; - const listResult = await auth().graphql({ query: listProjects }); + const listResult = await auth().graphql({ query: listProjects, variables: { limit: 1000 } }); const items = (listResult as any).data.listProjects.items; expect(Array.isArray(items)).toBe(true); @@ -294,7 +294,7 @@ describe('auth', () => { }); const created = (createResult as any).data.createTodo; - const listResult = await auth().graphql({ query: listTodos }); + const listResult = await auth().graphql({ query: listTodos, variables: { limit: 1000 } }); const items = (listResult as any).data.listTodos.items; expect(Array.isArray(items)).toBe(true); diff --git a/amplify-migration-apps/store-locator/migration/config.json b/amplify-migration-apps/store-locator/migration/config.json index 95d2cc9118d..7e2e0b46619 100644 --- a/amplify-migration-apps/store-locator/migration/config.json +++ b/amplify-migration-apps/store-locator/migration/config.json @@ -1,3 +1,4 @@ { - "refactor": { "skipValidations": true } + "refactorForward": { "skipValidations": true }, + "refactorRollback": { "skipValidations": true } } diff --git a/amplify-migration-apps/store-locator/migration/post-rollback.ts b/amplify-migration-apps/store-locator/migration/post-rollback.ts new file mode 100644 index 00000000000..9590da909a5 --- /dev/null +++ b/amplify-migration-apps/store-locator/migration/post-rollback.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env npx ts-node +/** + * Post-rollback script for store-locator app. + * + * Reverses the manual edits applied by post-refactor: + * 1. Comment back the postRefactor() call in amplify/backend.ts + */ + +import fs from 'fs/promises'; +import path from 'path'; + +async function commentPostRefactorCall(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + let content = await fs.readFile(backendPath, 'utf-8'); + + content = content.replace(/^(\s*)(postRefactor\(\);?)$/m, '$1// $2'); + + await fs.writeFile(backendPath, content, 'utf-8'); +} + +export async function postRollback(appPath: string): Promise { + await commentPostRefactorCall(appPath); +} + +async function main(): Promise { + const [appPath = process.cwd()] = process.argv.slice(2); + await postRollback(appPath); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/amplify-migration-apps/store-locator/package.json b/amplify-migration-apps/store-locator/package.json index 22e8ad44838..ec0a7cef6fb 100644 --- a/amplify-migration-apps/store-locator/package.json +++ b/amplify-migration-apps/store-locator/package.json @@ -24,6 +24,7 @@ "pre-push": "npx tsx migration/pre-push.ts", "post-generate": "npx tsx migration/post-generate.ts", "post-refactor": "npx tsx migration/post-refactor.ts", + "post-rollback": "npx tsx migration/post-rollback.ts", "post-sandbox": "true", "pre-sandbox": "true", "post-push": "true" diff --git a/amplify-migration-apps/store-locator/tests/auth.test.ts b/amplify-migration-apps/store-locator/tests/auth.test.ts index eed66edf4c0..d6c2a9e249b 100644 --- a/amplify-migration-apps/store-locator/tests/auth.test.ts +++ b/amplify-migration-apps/store-locator/tests/auth.test.ts @@ -61,11 +61,17 @@ describe('PostConfirmation trigger', () => { const username = `testuser-${randomBytes(4).toString('hex')}@test.example.com`; const password = `Test${randomBytes(4).toString('hex')}!Aa1`; - await signUp({ - username, - password, - options: { userAttributes: { email: username } }, - }); + try { + await signUp({ + username, + password, + options: { userAttributes: { email: username } }, + }); + } catch (err: any) { + // Ignore daily email limit errors — we don't need the verification email to actually send. + const isEmailLimitError = err.name === 'LimitExceededException' && err.message?.includes('Exceeded daily email limit'); + if (!isEmailLimitError) throw err; + } try { await cognito.send(new AdminConfirmSignUpCommand({ diff --git a/codebuild_specs/e2e_workflow_generated.yml b/codebuild_specs/e2e_workflow_generated.yml index 09e35bed9f7..3cfc115d3aa 100644 --- a/codebuild_specs/e2e_workflow_generated.yml +++ b/codebuild_specs/e2e_workflow_generated.yml @@ -967,75 +967,84 @@ batch: DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_store_locator + - identifier: l_gen2_migration_store_locator buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-store-locator.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-store-locator.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_project_boards + - identifier: l_gen2_migration_project_boards buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-project-boards.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-project-boards.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_product_catalog + - identifier: l_gen2_migration_product_catalog buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-product-catalog.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-product-catalog.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_mood_board + - identifier: l_gen2_migration_mood_board buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-mood-board.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-mood-board.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_media_vault + - identifier: l_gen2_migration_media_vault buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-media-vault.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-media-vault.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_fitness_tracker + - identifier: l_gen2_migration_fitness_tracker buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-fitness-tracker.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-fitness-tracker.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_discussions + - identifier: l_gen2_migration_finance_tracker buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-discussions.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-finance-tracker.test.ts DISABLE_COVERAGE: 1 depend-on: - upb - - identifier: l_migrate_backend_only + - identifier: l_gen2_migration_discussions buildspec: codebuild_specs/run_e2e_tests_linux.yml env: variables: compute-type: BUILD_GENERAL1_LARGE - TEST_SUITE: src/__tests__/gen2-migration/migrate-backend-only.test.ts + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-discussions.test.ts + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_gen2_migration_backend_only + buildspec: codebuild_specs/run_e2e_tests_linux.yml + env: + variables: + compute-type: BUILD_GENERAL1_LARGE + TEST_SUITE: src/__tests__/gen2-migration/gen2-migration-backend-only.test.ts DISABLE_COVERAGE: 1 depend-on: - upb diff --git a/codebuild_specs/wait_for_ids.json b/codebuild_specs/wait_for_ids.json index 4cf2609e142..aeaaddd6752 100644 --- a/codebuild_specs/wait_for_ids.json +++ b/codebuild_specs/wait_for_ids.json @@ -48,6 +48,15 @@ "l_function_3a_go_function_3a_dotnet_export_pull_b", "l_function_4_function_3b_function_2c", "l_function_migration_storage_3_schema_auth_9_c", + "l_gen2_migration_backend_only", + "l_gen2_migration_discussions", + "l_gen2_migration_finance_tracker", + "l_gen2_migration_fitness_tracker", + "l_gen2_migration_media_vault", + "l_gen2_migration_mood_board", + "l_gen2_migration_product_catalog", + "l_gen2_migration_project_boards", + "l_gen2_migration_store_locator", "l_general_config_headless_init_dynamodb_simulator_user_groups", "l_geo_add_d_geo_add_c", "l_geo_add_e", @@ -83,14 +92,6 @@ "l_ios_analytics_pinpoint_config_flutter_notifications_pinpoint_config_flutter_analytics_pinpoint_config", "l_javascript_notifications_pinpoint_config_javascript_analytics_pinpoint_config_ios_notifications_pinpoint_config", "l_js_frontend_config", - "l_migrate_backend_only", - "l_migrate_discussions", - "l_migrate_fitness_tracker", - "l_migrate_media_vault", - "l_migrate_mood_board", - "l_migrate_product_catalog", - "l_migrate_project_boards", - "l_migrate_store_locator", "l_notifications_analytics_compatibility_in_app_1_studio_modelgen", "l_notifications_apns_init_b_container_hosting", "l_notifications_in_app_messaging", diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/auth/auth.generator.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/auth/auth.generator.test.ts index a6a2bf946a5..9ae4ac09920 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/auth/auth.generator.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/auth/auth.generator.test.ts @@ -2091,8 +2091,9 @@ describe('AuthGenerator', () => { jest.spyOn(gen1App.aws, 'fetchUserPool').mockResolvedValue({ SchemaAttributes: [ { Name: 'email', Required: true, Mutable: true }, + { Name: 'address', Required: true, Mutable: false }, { Name: 'birthdate', Required: false, Mutable: true }, - { Name: 'address', Required: false, Mutable: true }, + { Name: 'given_name', Required: false, Mutable: false }, ], }); jest.spyOn(gen1App.aws, 'fetchMfaConfig').mockResolvedValue({}); @@ -2103,14 +2104,11 @@ describe('AuthGenerator', () => { IdentityPoolName: 'test-pool', AllowUnauthenticatedIdentities: false, }); - jest.spyOn(gen1App.aws, 'fetchUserPoolClient').mockImplementation((_poolId: string, clientId: string) => { - if (clientId === 'webclient123') return Promise.resolve({}); - return Promise.resolve({ - RefreshTokenValidity: 30, - EnableTokenRevocation: true, - ReadAttributes: ['birthdate', 'email'], - WriteAttributes: ['address', 'email'], - }); + jest.spyOn(gen1App.aws, 'fetchUserPoolClient').mockResolvedValue({ + RefreshTokenValidity: 30, + EnableTokenRevocation: true, + ReadAttributes: ['birthdate', 'email', 'given_name', 'address'], + WriteAttributes: ['address', 'email'], }); const generator = new AuthGenerator(gen1App, backendGenerator, outputDir, authResource, logger); @@ -2131,13 +2129,9 @@ describe('AuthGenerator', () => { required: true, mutable: true, }, - birthdate: { - required: false, - mutable: true, - }, address: { - required: false, - mutable: true, + required: true, + mutable: false, }, }, multifactor: { @@ -2162,6 +2156,8 @@ describe('AuthGenerator', () => { readAttributes: new ClientAttributes().withStandardAttributes({ birthdate: true, email: true, + givenName: true, + address: true, }), writeAttributes: new ClientAttributes().withStandardAttributes({ address: true, 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 index 3dbeb9b6bc6..6e9c2c425d8 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/lock.test.ts @@ -5,11 +5,6 @@ import { UpdateAppCommand } from '@aws-sdk/client-amplify'; import { SpinningLogger } from '../../../commands/gen2-migration/_common/spinning-logger'; import { Gen1App } from '../../../commands/gen2-migration/_common/gen1-app'; import { AmplifyGen2MigrationValidations } from '../../../commands/gen2-migration/_common/validations'; -import { detectTemplateDrift } from '../../../commands/drift/detect-template-drift'; - -jest.mock('../../../commands/drift/detect-template-drift', () => ({ - detectTemplateDrift: jest.fn(), -})); jest.mock('@aws-sdk/client-appsync', () => ({ ...jest.requireActual('@aws-sdk/client-appsync'), @@ -200,7 +195,13 @@ describe('AmplifyMigrationLockStep', () => { }); describe('rollback stack policy removal', () => { + /** Mocks the listNestedStack call that rollback now performs. */ + function setupRollbackNestedStackMock() { + mockCfnSend.mockResolvedValueOnce({ StackResources: [] }); + } + it('should remove lock statement and preserve customer statements', async () => { + setupRollbackNestedStackMock(); const policy = { Statement: [ { Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' }, @@ -224,6 +225,7 @@ describe('AmplifyMigrationLockStep', () => { }); it('should set allow-all when lock statement was the only one', async () => { + setupRollbackNestedStackMock(); const policy = { Statement: [{ Effect: 'Deny', Action: 'Update:*', Principal: '*', Resource: '*' }] }; mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(policy) }).mockResolvedValueOnce({}); mockAmplifySend @@ -240,6 +242,7 @@ describe('AmplifyMigrationLockStep', () => { }); it('should skip SetStackPolicy when no existing policy (lock not found)', async () => { + setupRollbackNestedStackMock(); mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined }); mockAmplifySend .mockResolvedValueOnce({ app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv' } } }) @@ -250,6 +253,7 @@ describe('AmplifyMigrationLockStep', () => { }); it('should skip SetStackPolicy when lock statement is not found', async () => { + setupRollbackNestedStackMock(); const policy = { Statement: [{ Effect: 'Deny', Action: 'Update:Replace', Principal: '*', Resource: 'LogicalResourceId/MyDB' }] }; mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: JSON.stringify(policy) }); mockAmplifySend @@ -263,6 +267,7 @@ describe('AmplifyMigrationLockStep', () => { describe('rollback env var removal', () => { it('should remove GEN2_MIGRATION_ENVIRONMENT_NAME and preserve other env vars', async () => { + mockCfnSend.mockResolvedValueOnce({ StackResources: [] }); mockCfnSend.mockResolvedValueOnce({ StackPolicyBody: undefined }); mockAmplifySend .mockResolvedValueOnce({ app: { environmentVariables: { GEN2_MIGRATION_ENVIRONMENT_NAME: 'testEnv', OTHER: 'keep' } } }) @@ -275,389 +280,92 @@ describe('AmplifyMigrationLockStep', () => { }); }); - describe('rollback drift validation', () => { - const mockDetectTemplateDrift = detectTemplateDrift as jest.MockedFunction; - - it('should pass validation when no drift is detected', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ changes: [], skipped: false }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(true); - expect(mockDetectTemplateDrift).toHaveBeenCalledWith('test-root-stack', mockLogger, expect.anything()); - }); - - it('should pass validation when only DeletionPolicy drift exists', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'MyTable', - ResourceType: 'AWS::DynamoDB::Table', - Scope: ['DeletionPolicy'], - Replacement: 'False', - }, - { - Action: 'Modify', - LogicalResourceId: 'MyBucket', - ResourceType: 'AWS::S3::Bucket', - Scope: ['DeletionPolicy'], - Replacement: 'False', - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(true); - }); - - it('should fail validation when real drift exists alongside DeletionPolicy drift', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'MyTable', - ResourceType: 'AWS::DynamoDB::Table', - Scope: ['DeletionPolicy'], - Replacement: 'False', - }, - { - Action: 'Modify', - LogicalResourceId: 'MyFunction', - ResourceType: 'AWS::Lambda::Function', - Scope: ['Properties'], - Replacement: 'False', - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should fail validation when drift detection is skipped', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [], - skipped: true, - skipReason: 'Changeset creation failed', - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should fail validation when drift detection throws an error', async () => { - mockDetectTemplateDrift.mockRejectedValueOnce(new Error('CFN client error')); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should not filter out Modify changes with multiple Scope entries that include DeletionPolicy', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'MyTable', - ResourceType: 'AWS::DynamoDB::Table', - Scope: ['DeletionPolicy', 'Properties'], - Replacement: 'False', - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); + describe('rollback stack integrity validation', () => { + let lockStepWithStorage: AmplifyMigrationLockStep; - it('should not filter out Add or Remove actions even with DeletionPolicy scope', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Add', - LogicalResourceId: 'NewResource', - ResourceType: 'AWS::DynamoDB::Table', - Scope: ['DeletionPolicy'], + beforeEach(() => { + lockStepWithStorage = new AmplifyMigrationLockStep( + mockLogger, + { + appId: 'test-app-id', + appName: 'testApp', + rootStackName: 'test-root-stack', + region: 'us-east-1', + envName: 'testEnv', + discover: () => [{ category: 'storage', service: 'DynamoDB', resourceName: 'myTable', key: 'storage:DynamoDB' as const }], + resourceMetaOutput: () => undefined, + json: () => ({ + Resources: { + DynamoDBTable: { Type: 'AWS::DynamoDB::Table' }, + TablePolicy: { Type: 'AWS::IAM::Policy' }, + }, + }), + clients: { + cloudFormation: { send: mockCfnSend }, + amplify: { send: mockAmplifySend }, + appSync: { send: jest.fn() }, + dynamoDB: { send: jest.fn() }, }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); + } as unknown as Gen1App, + {} as $TSContext, + { + validateDeploymentStatus: jest.fn().mockResolvedValue(undefined), + validateDrift: jest.fn().mockResolvedValue(undefined), + } as unknown as AmplifyGen2MigrationValidations, + ); }); - it('should fail validation when nested changes contain real drift at leaf level', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ + it('should pass validation when all local resources exist in deployed template', async () => { + // listNestedStack + mockCfnSend.mockResolvedValueOnce({ + StackResources: [ { - Action: 'Modify', - LogicalResourceId: 'authStack', ResourceType: 'AWS::CloudFormation::Stack', - Scope: ['Properties'], - nestedChanges: [ - { - Action: 'Modify', - LogicalResourceId: 'UserPool', - ResourceType: 'AWS::Cognito::UserPool', - Scope: ['Properties'], - }, - ], + LogicalResourceId: 'storagemyTable', + PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:123:stack/storage-stack/abc', }, ], - skipped: false, }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should fail validation when incompleteStacks are reported', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [], - skipped: false, - incompleteStacks: ['storageactivity'], - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should pass validation when only cascading IAM Policy drift from DeletionPolicy change exists', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'TodoTable', - ResourceType: 'AWS::DynamoDB::Table', - Scope: ['DeletionPolicy'], - Replacement: 'False', - }, - { - Action: 'Modify', - LogicalResourceId: 'TodoIAMRoleDefaultPolicy7BBBF45B', - ResourceType: 'AWS::IAM::Policy', - Scope: ['Properties'], - Details: [ - { - ChangeSource: 'ResourceAttribute', - Evaluation: 'Dynamic', - Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' }, - CausingEntity: 'TodoTable.Arn', - }, - ], - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(true); - }); - - it('should fail validation when IAM Policy has a static/direct change mixed with dynamic', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'TodoIAMRoleDefaultPolicy7BBBF45B', - ResourceType: 'AWS::IAM::Policy', - Scope: ['Properties'], - Details: [ - { - ChangeSource: 'ResourceAttribute', - Evaluation: 'Dynamic', - Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' }, - CausingEntity: 'TodoTable.Arn', - }, - { - ChangeSource: 'DirectModification', - Evaluation: 'Static', - Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' }, - }, - ], - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should fail validation when IAM Policy change requires recreation', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'SomePolicy', - ResourceType: 'AWS::IAM::Policy', - Scope: ['Properties'], - Details: [ - { - ChangeSource: 'ResourceAttribute', - Evaluation: 'Dynamic', - Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Conditionally' }, - CausingEntity: 'TodoTable.Arn', - }, - ], - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should pass validation when nested tree has only DeletionPolicy and cascading IAM drift', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'apiStack', - ResourceType: 'AWS::CloudFormation::Stack', - Scope: ['Properties'], - nestedChanges: [ - { - Action: 'Modify', - LogicalResourceId: 'TodoTable', - ResourceType: 'AWS::DynamoDB::Table', - Scope: ['DeletionPolicy'], - }, - { - Action: 'Modify', - LogicalResourceId: 'TodoIAMRoleDefaultPolicy', - ResourceType: 'AWS::IAM::Policy', - Scope: ['Properties'], - Details: [ - { - ChangeSource: 'ResourceAttribute', - Evaluation: 'Dynamic', - Target: { Attribute: 'Properties', RequiresRecreation: 'Never' }, - CausingEntity: 'TodoTable.Arn', - }, - ], - }, - ], + // fetchTemplate for the nested stack + mockCfnSend.mockResolvedValueOnce({ + TemplateBody: JSON.stringify({ + Resources: { + DynamoDBTable: { Type: 'AWS::DynamoDB::Table' }, + TablePolicy: { Type: 'AWS::IAM::Policy' }, }, - ], - skipped: false, + }), }); - const plan = await lockStep.rollback(); + const plan = await lockStepWithStorage.rollback(); const valid = await plan.validate(); expect(valid).toBe(true); }); - it('should fail validation when IAM Policy cascading change is from a non-table resource', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'SomeLambdaPolicy', - ResourceType: 'AWS::IAM::Policy', - Scope: ['Properties'], - Details: [ - { - ChangeSource: 'ResourceAttribute', - Evaluation: 'Dynamic', - Target: { Attribute: 'Properties', Name: 'PolicyDocument', RequiresRecreation: 'Never' }, - CausingEntity: 'MyLambdaFunction.Arn', - }, - ], - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should fail validation when a nested stack is added (Add action on CloudFormation::Stack)', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ + it('should fail validation when a resource is missing from the deployed template', async () => { + // listNestedStack + mockCfnSend.mockResolvedValueOnce({ + StackResources: [ { - Action: 'Add', - LogicalResourceId: 'newUnexpectedStack', ResourceType: 'AWS::CloudFormation::Stack', + LogicalResourceId: 'storagemyTable', + PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:123:stack/storage-stack/abc', }, ], - skipped: false, }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(false); - }); - - it('should fail validation when a nested stack is removed (Remove action on CloudFormation::Stack)', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Remove', - LogicalResourceId: 'authStack', - ResourceType: 'AWS::CloudFormation::Stack', - PhysicalResourceId: 'arn:aws:cloudformation:us-east-1:123:stack/auth-stack/abc', + // fetchTemplate - missing TablePolicy + mockCfnSend.mockResolvedValueOnce({ + TemplateBody: JSON.stringify({ + Resources: { + DynamoDBTable: { Type: 'AWS::DynamoDB::Table' }, }, - ], - skipped: false, + }), }); - const plan = await lockStep.rollback(); + const plan = await lockStepWithStorage.rollback(); const valid = await plan.validate(); expect(valid).toBe(false); }); - - it('should pass validation when CloudFormation::Stack wrapper has no nestedChanges', async () => { - mockDetectTemplateDrift.mockResolvedValueOnce({ - changes: [ - { - Action: 'Modify', - LogicalResourceId: 'apiStack', - ResourceType: 'AWS::CloudFormation::Stack', - Scope: ['Properties'], - }, - ], - skipped: false, - }); - - const plan = await lockStep.rollback(); - const valid = await plan.validate(); - - expect(valid).toBe(true); - }); }); }); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts index 5e3dbc47099..949c6c5229a 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-forward.test.ts @@ -18,7 +18,11 @@ import { ResourceMapping, } from '@aws-sdk/client-cloudformation'; import { SSMClient } from '@aws-sdk/client-ssm'; -import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import { + CognitoIdentityProviderClient, + DescribeUserPoolCommand, + ListIdentityProvidersCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import { Cfn } from '../../../../../commands/gen2-migration/_common/cfn'; const ts = new Date(); @@ -263,3 +267,227 @@ describe('buildImportSpec', () => { ); }); }); + +describe('AuthCognitoForwardRefactorer — holding stack behavior', () => { + let cfnMock: ReturnType; + let ssmMock: ReturnType; + let cognitoMock: ReturnType; + + const gen2AuthTemplateWithSocialAuth: CFNTemplate = { + AWSTemplateFormatVersion: '2010-09-09', + Description: 'gen2 auth', + Resources: { + amplifyAuthUserPool12345678: { Type: 'AWS::Cognito::UserPool', Properties: {} }, + amplifyAuthUserPoolDomain12345678: { + Type: 'AWS::Cognito::UserPoolDomain', + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + Properties: { Domain: 'test-domain', UserPoolId: 'us-east-1_TEST' }, + }, + amplifyAuthUserPoolIdentityProviderGoogle12345678: { + Type: 'AWS::Cognito::UserPoolIdentityProvider', + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + Properties: { ProviderName: 'Google', ProviderType: 'Google', UserPoolId: 'us-east-1_TEST' }, + }, + }, + Outputs: {}, + }; + + beforeEach(() => { + cfnMock = mockClient(CloudFormationClient); + ssmMock = mockClient(SSMClient); + cognitoMock = mockClient(CognitoIdentityProviderClient); + }); + + afterEach(() => { + cfnMock.restore(); + ssmMock.restore(); + cognitoMock.restore(); + }); + + function setupSocialAuthMocks(holdingStackExists: boolean) { + cfnMock.on(DescribeStacksCommand).resolves({ Stacks: [] }); + + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen1-root' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'authtestStack', + ResourceType: 'AWS::CloudFormation::Stack', + PhysicalResourceId: 'gen1-auth-stack', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-root' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'authStack', + ResourceType: 'AWS::CloudFormation::Stack', + PhysicalResourceId: 'gen2-auth-stack', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen1-auth-stack' }).resolves({ StackResources: [] }); + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-auth-stack' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'amplifyAuthUserPool12345678', + ResourceType: 'AWS::Cognito::UserPool', + PhysicalResourceId: 'us-east-1_TEST', + Timestamp: ts, + ResourceStatus: rs, + }, + { + LogicalResourceId: 'amplifyAuthUserPoolDomain12345678', + ResourceType: 'AWS::Cognito::UserPoolDomain', + PhysicalResourceId: 'test-domain', + Timestamp: ts, + ResourceStatus: rs, + }, + { + LogicalResourceId: 'amplifyAuthUserPoolIdentityProviderGoogle12345678', + ResourceType: 'AWS::Cognito::UserPoolIdentityProvider', + PhysicalResourceId: 'Google', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + + cfnMock.on(DescribeStacksCommand, { StackName: 'gen1-auth-stack' }).resolves({ + Stacks: [ + { + StackName: 'gen1-auth-stack', + StackStatus: rs, + CreationTime: ts, + Description: gen1AuthTemplate.Description, + Parameters: [], + Outputs: [], + }, + ], + }); + cfnMock.on(DescribeStacksCommand, { StackName: 'gen2-auth-stack' }).resolves({ + Stacks: [{ StackName: 'gen2-auth-stack', StackStatus: rs, CreationTime: ts, Parameters: [], Outputs: [] }], + }); + + if (holdingStackExists) { + cfnMock.on(DescribeStacksCommand, { StackName: 'gen2-auth-stack-holding' }).resolves({ + Stacks: [{ StackName: 'gen2-auth-stack-holding', StackStatus: 'UPDATE_COMPLETE', CreationTime: ts }], + }); + cfnMock.on(GetTemplateCommand, { StackName: 'gen2-auth-stack-holding' }).resolves({ + TemplateBody: JSON.stringify({ + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + amplifyAuthUserPool12345678: { Type: 'AWS::Cognito::UserPool', Properties: {} }, + }, + Outputs: {}, + }), + }); + } + + cfnMock.on(GetTemplateCommand, { StackName: 'gen1-auth-stack' }).resolves({ TemplateBody: JSON.stringify(gen1AuthTemplate) }); + cfnMock + .on(GetTemplateCommand, { StackName: 'gen2-auth-stack' }) + .resolves({ TemplateBody: JSON.stringify(gen2AuthTemplateWithSocialAuth) }); + + cfnMock.on(CreateChangeSetCommand).resolves({}); + cfnMock.on(DescribeChangeSetCommand).callsFake((input) => ({ Status: 'CREATE_COMPLETE', StackName: input.StackName, Changes: [] })); + cfnMock.on(ExecuteChangeSetCommand).resolves({}); + cfnMock.on(DeleteChangeSetCommand).resolves({}); + } + + function createForwardRefactorer() { + const clients = new (AwsClients as any)({ region: 'us-east-1' }); + (clients as any).cloudFormation = new CloudFormationClient({}); + const gen1Env = new StackFacade(clients, 'gen1-root'); + const gen2Branch = new StackFacade(clients, 'gen2-root'); + return new AuthCognitoForwardRefactorer( + gen1Env, + gen2Branch, + { + region: 'us-east-1', + clients, + appId: 'appId', + envName: 'main', + resourceMetaOutput: () => 'us-east-1_TEST', + } as unknown as Gen1App, + '123456789', + noOpLogger(), + { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, + new Cfn(new CloudFormationClient({}), noOpLogger()), + ); + } + + it('beforeMove skips orphan when holding stack exists', async () => { + setupSocialAuthMocks(true); + const refactorer = createForwardRefactorer(); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + // Should NOT contain orphan operation for social auth + expect(descriptions.some((d) => d.includes('Orphan'))).toBe(false); + }); + + it('beforeMove includes orphan when no holding stack', async () => { + setupSocialAuthMocks(false); + const refactorer = createForwardRefactorer(); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + // Should contain orphan operation for social auth + expect(descriptions.some((d) => d.includes('Orphan'))).toBe(true); + }); + + it('move skips import when holding stack exists', async () => { + setupSocialAuthMocks(true); + const refactorer = createForwardRefactorer(); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + // Should NOT contain import operation + expect(descriptions.some((d) => d.includes('Import social auth'))).toBe(false); + }); + + it('move includes import when no holding stack', async () => { + setupSocialAuthMocks(false); + const refactorer = createForwardRefactorer(); + + cognitoMock.on(DescribeUserPoolCommand).resolves({ + UserPool: { + Id: 'us-east-1_TEST', + Domain: 'test-domain', + }, + }); + cognitoMock.on(ListIdentityProvidersCommand).resolves({ + Providers: [{ ProviderName: 'Google', ProviderType: 'Google' }], + }); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + // Should contain import operation + expect(descriptions.some((d) => d.includes('Import social auth'))).toBe(true); + }); + + it('throws StackStateError when holding stack is in unexpected state', async () => { + setupSocialAuthMocks(false); + // Override the holding stack mock to return an unexpected status + cfnMock.on(DescribeStacksCommand, { StackName: 'gen2-auth-stack-holding' }).resolves({ + Stacks: [{ StackName: 'gen2-auth-stack-holding', StackStatus: 'ROLLBACK_COMPLETE', CreationTime: ts }], + }); + + const refactorer = createForwardRefactorer(); + + await expect(refactorer.plan()).rejects.toMatchObject({ + name: 'StackStateError', + message: expect.stringContaining('ROLLBACK_COMPLETE'), + }); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts index 3d5883d0dac..4effa886425 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/auth/auth-cognito-rollback.test.ts @@ -15,6 +15,11 @@ import { DescribeChangeSetCommand, DeleteChangeSetCommand, } from '@aws-sdk/client-cloudformation'; +import { + CognitoIdentityProviderClient, + DescribeUserPoolCommand, + ListIdentityProvidersCommand, +} from '@aws-sdk/client-cognito-identity-provider'; import { Cfn } from '../../../../../commands/gen2-migration/_common/cfn'; const ts = new Date(); @@ -176,3 +181,235 @@ describe('AuthCognitoRollbackRefactorer.targetLogicalId', () => { expect(refactorer.testTargetLogicalId('SomeResource', 'AWS::Lambda::Function')).toBeUndefined(); }); }); + +describe('AuthCognitoRollbackRefactorer — holding stack behavior', () => { + let cfnMock: ReturnType; + let cognitoMock: ReturnType; + + const gen2AuthTemplateWithSocialAuth: CFNTemplate = { + AWSTemplateFormatVersion: '2010-09-09', + Description: 'gen2 auth', + Resources: { + amplifyAuthUserPool12345678: { Type: 'AWS::Cognito::UserPool', Properties: {} }, + amplifyAuthUserPoolAppClient12345678: { Type: 'AWS::Cognito::UserPoolClient', Properties: {} }, + amplifyAuthUserPoolDomain12345678: { + Type: 'AWS::Cognito::UserPoolDomain', + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + Properties: { Domain: 'test-domain', UserPoolId: 'us-east-1_TEST' }, + }, + amplifyAuthUserPoolIdentityProviderGoogle12345678: { + Type: 'AWS::Cognito::UserPoolIdentityProvider', + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + Properties: { ProviderName: 'Google', ProviderType: 'Google', UserPoolId: 'us-east-1_TEST' }, + }, + }, + Outputs: {}, + }; + + beforeEach(() => { + cfnMock = mockClient(CloudFormationClient); + cognitoMock = mockClient(CognitoIdentityProviderClient); + }); + + afterEach(() => { + cfnMock.restore(); + cognitoMock.restore(); + }); + + function setupRollbackMocks(holdingStackExists: boolean) { + cfnMock.on(DescribeStacksCommand).resolves({ Stacks: [] }); + + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-root' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'authStack', + ResourceType: 'AWS::CloudFormation::Stack', + PhysicalResourceId: 'gen2-auth', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen1-root' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'authtestMain', + ResourceType: 'AWS::CloudFormation::Stack', + PhysicalResourceId: 'gen1-auth', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-auth' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'amplifyAuthUserPool12345678', + ResourceType: 'AWS::Cognito::UserPool', + PhysicalResourceId: 'us-east-1_TEST', + Timestamp: ts, + ResourceStatus: rs, + }, + { + LogicalResourceId: 'amplifyAuthUserPoolAppClient12345678', + ResourceType: 'AWS::Cognito::UserPoolClient', + PhysicalResourceId: 'client-id', + Timestamp: ts, + ResourceStatus: rs, + }, + { + LogicalResourceId: 'amplifyAuthUserPoolDomain12345678', + ResourceType: 'AWS::Cognito::UserPoolDomain', + PhysicalResourceId: 'test-domain', + Timestamp: ts, + ResourceStatus: rs, + }, + { + LogicalResourceId: 'amplifyAuthUserPoolIdentityProviderGoogle12345678', + ResourceType: 'AWS::Cognito::UserPoolIdentityProvider', + PhysicalResourceId: 'Google', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen1-auth' }).resolves({ StackResources: [] }); + + cfnMock.on(DescribeStacksCommand, { StackName: 'gen2-auth' }).resolves({ + Stacks: [{ StackName: 'gen2-auth', StackStatus: rs, CreationTime: ts, Parameters: [], Outputs: [] }], + }); + cfnMock.on(DescribeStacksCommand, { StackName: 'gen1-auth' }).resolves({ + Stacks: [ + { + StackName: 'gen1-auth', + StackStatus: rs, + CreationTime: ts, + Description: gen1AuthTemplate.Description, + Parameters: [], + Outputs: [], + }, + ], + }); + + if (holdingStackExists) { + cfnMock.on(DescribeStacksCommand, { StackName: 'gen2-auth-holding' }).resolves({ + Stacks: [{ StackName: 'gen2-auth-holding', StackStatus: 'UPDATE_COMPLETE', CreationTime: ts }], + }); + // holding stack has a user pool + cfnMock.on(DescribeStackResourcesCommand, { StackName: 'gen2-auth-holding' }).resolves({ + StackResources: [ + { + LogicalResourceId: 'amplifyAuthUserPool12345678', + ResourceType: 'AWS::Cognito::UserPool', + PhysicalResourceId: 'us-east-1_HOLDING', + Timestamp: ts, + ResourceStatus: rs, + }, + ], + }); + cfnMock.on(GetTemplateCommand, { StackName: 'gen2-auth-holding' }).resolves({ + TemplateBody: JSON.stringify({ + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + amplifyAuthUserPool12345678: { Type: 'AWS::Cognito::UserPool', Properties: {} }, + }, + Outputs: {}, + }), + }); + } + + cfnMock.on(GetTemplateCommand, { StackName: 'gen2-auth' }).resolves({ TemplateBody: JSON.stringify(gen2AuthTemplateWithSocialAuth) }); + cfnMock.on(GetTemplateCommand, { StackName: 'gen1-auth' }).resolves({ TemplateBody: JSON.stringify(gen1AuthTemplate) }); + + cfnMock.on(CreateChangeSetCommand).resolves({}); + cfnMock.on(DescribeChangeSetCommand).resolves({ Status: 'CREATE_COMPLETE', Changes: [] }); + cfnMock.on(DeleteChangeSetCommand).resolves({}); + } + + function createRollbackRefactorer() { + const clients = new (AwsClients as any)({ region: 'us-east-1' }); + (clients as any).cloudFormation = new CloudFormationClient({}); + return new AuthCognitoRollbackRefactorer( + new StackFacade(clients, 'gen1-root'), + new StackFacade(clients, 'gen2-root'), + { region: 'us-east-1', clients } as unknown as Gen1App, + '123', + noOpLogger(), + { category: 'auth', resourceName: 'test', service: 'Cognito', key: 'auth:Cognito' as const }, + new Cfn(new CloudFormationClient({}), noOpLogger()), + ); + } + + it('move includes orphan when holding stack exists', async () => { + setupRollbackMocks(true); + const refactorer = createRollbackRefactorer(); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + expect(descriptions.some((d) => d.includes('Orphan'))).toBe(true); + }); + + it('move skips orphan when no holding stack', async () => { + setupRollbackMocks(false); + const refactorer = createRollbackRefactorer(); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + expect(descriptions.some((d) => d.includes('Orphan'))).toBe(false); + }); + + it('afterMove includes import when holding stack exists', async () => { + setupRollbackMocks(true); + + cognitoMock.on(DescribeUserPoolCommand).resolves({ + UserPool: { Id: 'us-east-1_HOLDING', Domain: 'test-domain' }, + }); + cognitoMock.on(ListIdentityProvidersCommand).resolves({ + Providers: [{ ProviderName: 'Google', ProviderType: 'Google' }], + }); + + const refactorer = createRollbackRefactorer(); + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + expect(descriptions.some((d) => d.includes('Import social auth'))).toBe(true); + }); + + it('afterMove skips import when no holding stack', async () => { + setupRollbackMocks(false); + const refactorer = createRollbackRefactorer(); + + const ops = await refactorer.plan(); + const descriptions = (await Promise.all(ops.map((o) => o.describe()))).flat(); + + expect(descriptions.some((d) => d.includes('Import social auth'))).toBe(false); + }); + + it('throws StackStateError when holding stack is in unexpected state', async () => { + setupRollbackMocks(false); + // Override the holding stack mock to return an unexpected status + cfnMock.on(DescribeStacksCommand, { StackName: 'gen2-auth-holding' }).resolves({ + Stacks: [{ StackName: 'gen2-auth-holding', StackStatus: 'ROLLBACK_COMPLETE', CreationTime: ts }], + }); + cfnMock.on(GetTemplateCommand, { StackName: 'gen2-auth-holding' }).resolves({ + TemplateBody: JSON.stringify({ + AWSTemplateFormatVersion: '2010-09-09', + Resources: { + amplifyAuthUserPool12345678: { Type: 'AWS::Cognito::UserPool', Properties: {} }, + }, + Outputs: {}, + }), + }); + + const refactorer = createRollbackRefactorer(); + + await expect(refactorer.plan()).rejects.toMatchObject({ + name: 'StackStateError', + message: expect.stringContaining('ROLLBACK_COMPLETE'), + }); + }); +}); diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts index 26cbe8fa6d6..310e06da513 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/forward-category-refactorer.test.ts @@ -140,6 +140,31 @@ describe('ForwardCategoryRefactorer.beforeMove', () => { expect(cfnMock.commandCalls(DeleteStackCommand).length).toBeGreaterThan(0); }); + + it('throws StackStateError when holding stack is in unexpected state', async () => { + cfnMock.on(DescribeStacksCommand).resolves({ + Stacks: [{ StackName: 'holding', StackStatus: 'ROLLBACK_COMPLETE', CreationTime: new Date() }], + }); + cfnMock.on(GetTemplateCommand).resolves({ TemplateBody: GEN2_TEMPLATE_WITH_BUCKET }); + + const clients = new (AwsClients as any)({ region: 'us-east-1' }); + (clients as any).cloudFormation = new CloudFormationClient({}); + const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); + const refactorer = new TestForwardRefactorer( + new StackFacade(clients, 'g1'), + new StackFacade(clients, 'g2'), + { region: 'us-east-1', clients } as unknown as Gen1App, + '123', + noOpLogger(), + { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' as const }, + cfn, + ); + + await expect((refactorer as any).beforeMove('gen2-stack')).rejects.toMatchObject({ + name: 'StackStateError', + message: expect.stringContaining('ROLLBACK_COMPLETE'), + }); + }); }); import { CFNResource } from '../../../../../commands/gen2-migration/_common/cfn-template'; diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts index c941ac1f652..5ca197c666a 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.test.ts @@ -121,6 +121,30 @@ describe('RollbackCategoryRefactorer.afterMove', () => { const operations = await (refactorer as any).afterMove('gen2-auth-stack-id'); expect(operations).toHaveLength(0); }); + + it('throws StackStateError when holding stack is in unexpected state', async () => { + cfnMock.on(DescribeStacksCommand).resolves({ + Stacks: [{ StackName: 'holding', StackStatus: 'ROLLBACK_COMPLETE', CreationTime: new Date() }], + }); + + const clients = new (AwsClients as any)({ region: 'us-east-1' }); + (clients as any).cloudFormation = new CloudFormationClient({}); + const cfn = new Cfn(new CloudFormationClient({}), noOpLogger()); + const refactorer = new TestRollbackRefactorer( + new StackFacade(clients, 'gen1-root'), + new StackFacade(clients, 'gen2-root'), + { region: 'us-east-1', clients } as unknown as Gen1App, + '123456789', + noOpLogger(), + { category: 'storage', resourceName: 'test', service: 'S3', key: 'storage:S3' }, + cfn, + ); + + await expect((refactorer as any).afterMove('gen2-auth-stack-id')).rejects.toMatchObject({ + name: 'StackStateError', + message: expect.stringContaining('ROLLBACK_COMPLETE'), + }); + }); }); class TestRollbackMappingRefactorer extends RollbackCategoryRefactorer { diff --git a/packages/amplify-cli/src/commands/gen2-migration/_common/cfn.ts b/packages/amplify-cli/src/commands/gen2-migration/_common/cfn.ts index 5a7900ba467..1db7c949dd1 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/_common/cfn.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/_common/cfn.ts @@ -50,6 +50,14 @@ const EMPTY_HOLDING_TEMPLATE: CFNTemplate = { }; export const HOLDING_STACK_NAME_SUFFIX = '-holding'; + +/** + * Valid CloudFormation stack statuses for a holding stack. + * UPDATE_COMPLETE: normal state after successful refactor. + * UPDATE_ROLLBACK_COMPLETE: stack rolled back a failed update but is still usable. + * CREATE_COMPLETE: initial state when the stack was just created. + */ +export const VALID_HOLDING_STACK_STATUSES = ['UPDATE_COMPLETE', 'UPDATE_ROLLBACK_COMPLETE', 'CREATE_COMPLETE']; export const MIGRATION_PLACEHOLDER_LOGICAL_ID = 'MigrationPlaceholder'; export const MIGRATION_PLACEHOLDER_RESOURCE: CFNResource = { Type: 'AWS::CloudFormation::WaitConditionHandle', Properties: {} }; diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts index 7edb31dd12b..73935e53a08 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/auth/auth.renderer.ts @@ -415,7 +415,15 @@ export class AuthRenderer { if (!schema) return {}; const result: Record = {}; for (const attribute of schema) { + // skip if the attribute is not a standard one (i.e custom:) if (!attribute.Name || !(attribute.Name in MAPPED_USER_ATTRIBUTE_NAME)) continue; + + // optional attributes are skipped because this gen2 property (userAttributes) + // only maps to the required attributes. + // https://github.com/aws-amplify/amplify-backend/blob/757e2ce01616ad0c24547c541f1be4d389fd408b/packages/auth-construct/src/types.ts#L599-L602 + // where do optional attributes go? unclear, this might be a gap we have, or it might be that Gen1 has no option to set optional attributes. + if (!attribute.Required) continue; + result[MAPPED_USER_ATTRIBUTE_NAME[attribute.Name]] = { required: attribute.Required, mutable: attribute.Mutable, diff --git a/packages/amplify-cli/src/commands/gen2-migration/lock.ts b/packages/amplify-cli/src/commands/gen2-migration/lock.ts index 6366701c829..17866886943 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/lock.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/lock.ts @@ -16,7 +16,8 @@ import { extractStackNameFromId } from './_common/utils'; import { Cfn } from './_common/cfn'; import { AUTH_HOSTED_UI_LOGICAL_IDS_TO_RETAIN, RESOURCES_TO_RETAIN } from './_common/resource-types'; import { AmplifyError } from '@aws-amplify/amplify-cli-core'; -import { detectTemplateDrift, type ResourceChangeWithNested } from '../drift/detect-template-drift'; +import { CFNTemplate } from './_common/cfn-template'; +import CLITable from 'cli-table3'; const GEN2_MIGRATION_ENVIRONMENT_NAME = 'GEN2_MIGRATION_ENVIRONMENT_NAME'; @@ -47,67 +48,6 @@ const ALLOW_ALL_POLICY = { ], }; -/** - * Identifies changeset changes that are expected DeletionPolicy drift from the lock step. - * - * The lock step adds `DeletionPolicy: Retain` to stateful resources. These show up as: - * 1. Direct DeletionPolicy changes — Modify with Scope exactly `['DeletionPolicy']` - * 2. Cascading IAM Policy changes — CFN flags IAM policies that reference the modified - * table's attributes (e.g., `TodoTable.Arn` in PolicyDocument) as Dynamic re-evaluations. - * These have `ChangeSource: ResourceAttribute`, `Evaluation: Dynamic`, - * `RequiresRecreation: Never`, and `CausingEntity` matching `*Table.Arn` or - * `*Table.StreamArn` — they are harmless re-evaluations, not actual changes. - * - * For lock rollback to determine whether the environment is safe to revert, these expected - * changes must be filtered out so only real drift blocks the rollback. - */ -const isExpectedLockDrift = (change: ResourceChangeWithNested): boolean => { - if (change.Action !== 'Modify') return false; - - // Direct DeletionPolicy change on a resource - if (change.Scope?.length === 1 && change.Scope[0] === 'DeletionPolicy') return true; - - // Cascading IAM Policy change caused by DeletionPolicy modification on a referenced resource. - // Must be: Properties-only scope, all Details are Dynamic ResourceAttribute re-evaluations - // with CausingEntity referencing a table attribute (e.g., TodoTable.Arn, TodoTable.StreamArn). - if ( - change.ResourceType === 'AWS::IAM::Policy' && - change.Scope?.length === 1 && - change.Scope[0] === 'Properties' && - change.Details?.length - ) { - return change.Details.every( - (d) => - d.ChangeSource === 'ResourceAttribute' && - d.Evaluation === 'Dynamic' && - d.Target?.RequiresRecreation === 'Never' && - /Table\.(Arn|StreamArn)$/.test(d.CausingEntity ?? ''), - ); - } - - return false; -}; - -/** - * Recursively walks the change tree to find any leaf resource changes that are - * not expected lock drift. AWS::CloudFormation::Stack entries are structural - * wrappers — their nestedChanges contain the actual resource-level changes. - */ -function hasRealDrift(changes: ResourceChangeWithNested[]): boolean { - for (const change of changes) { - if (change.nestedChanges?.length) { - if (hasRealDrift(change.nestedChanges)) return true; - } else if (change.ResourceType !== 'AWS::CloudFormation::Stack') { - if (!isExpectedLockDrift(change)) return true; - } else if (change.Action !== 'Modify') { - // Add/Remove on a CloudFormation::Stack without nestedChanges is real drift — - // an entire nested stack was added or deleted outside Amplify. - return true; - } - } - return false; -} - export class AmplifyMigrationLockStep extends AmplifyMigrationStep { private _dynamoTableNames: string[] | undefined; @@ -232,13 +172,6 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { public async rollback(): Promise { const operations: AmplifyMigrationOperation[] = []; - operations.push({ - describe: async () => [], - validate: () => ({ description: 'Drift', run: () => this.validateLockRollbackDrift() }), - // eslint-disable-next-line @typescript-eslint/no-empty-function - execute: async () => {}, - }); - // ============================================================ // Project Level Operations // ============================================================ @@ -281,6 +214,54 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { }, }); + // ============================================================ + // Resource Specific Operations + // ============================================================ + + const nestedStacks = await this.listNestedStack(this.gen1App.rootStackName); + for (const resource of this.gen1App.discover()) { + switch (resource.key) { + case 'auth:Cognito': + case 'auth:Cognito-UserPool-Groups': { + const stackId = this.findNestedStack(nestedStacks, `${resource.category}${resource.resourceName}`); + const template = this.gen1App.json(`auth/${resource.resourceName}/build/${resource.resourceName}-cloudformation-template.json`); + operations.push(await this.validateRefactorRollbackStackIntegrity(resource, template, stackId)); + break; + } + case 'storage:S3': { + const stackId = this.findNestedStack(nestedStacks, `${resource.category}${resource.resourceName}`); + const template = this.gen1App.json(`storage/${resource.resourceName}/build/cloudformation-template.json`); + operations.push(await this.validateRefactorRollbackStackIntegrity(resource, template, stackId)); + break; + } + case 'storage:DynamoDB': { + const stackId = this.findNestedStack(nestedStacks, `${resource.category}${resource.resourceName}`); + const template = this.gen1App.json( + `storage/${resource.resourceName}/build/${resource.resourceName}-cloudformation-template.json`, + ); + operations.push(await this.validateRefactorRollbackStackIntegrity(resource, template, stackId)); + break; + } + case 'analytics:Kinesis': { + const stackId = this.findNestedStack(nestedStacks, `${resource.category}${resource.resourceName}`); + const template = this.gen1App.json(`analytics/${resource.resourceName}/kinesis-cloudformation-template.json`); + operations.push(await this.validateRefactorRollbackStackIntegrity(resource, template, stackId)); + break; + } + + case 'api:AppSync': + case 'api:API Gateway': + case 'geo:Map': + case 'geo:PlaceIndex': + case 'geo:GeofenceCollection': + case 'function:Lambda': + case 'custom:customCDK': + case 'UNKNOWN': + // untouched during refactor - skip them. + break; + } + } + return new Plan({ operations, logger: this.logger, @@ -310,45 +291,6 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { } } - /** - * Validates that the environment is safe for lock rollback by running template drift - * detection and filtering out expected DeletionPolicy changes from the lock step. - * - * If only DeletionPolicy drift remains (from the lock step adding Retain), rollback - * is safe. If any real drift exists, rollback must be blocked — the environment is - * in an inconsistent state. - */ - private async validateLockRollbackDrift(): Promise { - try { - const driftResults = await detectTemplateDrift(this.gen1App.rootStackName, this.logger, this.gen1App.clients.cloudFormation); - - if (driftResults.skipped) { - return { valid: false, report: `Template drift detection was skipped: ${driftResults.skipReason}` }; - } - - if (driftResults.incompleteStacks?.length) { - return { - valid: false, - report: `Could not verify all stacks for drift: ${driftResults.incompleteStacks.join(', ')}`, - }; - } - - // Check incompleteStacks before hasRealDrift — incomplete stacks mean we can't - // trust that the absence of real drift is accurate. - if (hasRealDrift(driftResults.changes)) { - return { - valid: false, - report: 'Template drift detected beyond expected DeletionPolicy changes', - }; - } - - return { valid: true }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (e: any) { - return { valid: false, report: e?.message ?? String(e) }; - } - } - private async findGraphQLApiId(): Promise { const graphQL = this.gen1App.discover().find((r) => r.category === 'api' && r.service === 'AppSync'); if (!graphQL) { @@ -450,6 +392,51 @@ export class AmplifyMigrationLockStep extends AmplifyMigrationStep { return operations; } + private async validateRefactorRollbackStackIntegrity( + resource: DiscoveredResource, + localTemplate: CFNTemplate, + stackId: string, + ): Promise { + const stackName = extractStackNameFromId(stackId); + + return { + resource, + validate: () => ({ + description: `Stack Integrity: ${stackName}`, + run: async () => { + const cfn = new Cfn(this.gen1App.clients.cloudFormation, this.logger); + const deployedTemplate = await cfn.fetchTemplate(stackId); + + const missingResources = new CLITable({ + head: ['Logical ID', 'Type'], + style: { head: [] }, + }); + + for (const logicalId of Object.keys(localTemplate.Resources)) { + const localResource = localTemplate.Resources[logicalId]; + if (localResource.Condition) { + // skip conditional resources since refactor resolves + // conditions so these resource may intentionally be missing + // from the deployed template. + continue; + } + if (!deployedTemplate.Resources[logicalId]) { + missingResources.push([logicalId, localResource.Type]); + } + } + + return { + valid: missingResources.length === 0, + report: `Following resources are missing. Did you forget to run 'amplify gen2-migration refactor --rollback'?\n\n${missingResources.toString()}`, + }; + }, + }), + describe: async () => [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + execute: async () => {}, + }; + } + private validateRetainChangeset(changeSet: DescribeChangeSetOutput): boolean { const changes = changeSet.Changes ?? []; if (changes.length === 0) return false; diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts index bda2e26d304..9abcd54249c 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-forward.ts @@ -5,7 +5,8 @@ import { checkRetainPolicies, RefactorBlueprint } from '../workflow/category-ref import { CFNResource, CFNTemplate } from '../../_common/cfn-template'; import { AmplifyMigrationOperation } from '../../_common/operation'; import { extractStackNameFromId } from '../../_common/utils'; -import { SocialAuthConfig } from '../stack-facade'; +import { VALID_HOLDING_STACK_STATUSES } from '../../_common/cfn'; +import { SocialAuthConfig, StackFacade } from '../stack-facade'; import CLITable from 'cli-table3'; export const GEN1_NATIVE_APP_CLIENT = 'UserPoolClient'; @@ -135,11 +136,28 @@ export function renderImportTable(resourcesToImport: ResourceToImport[], gen2Sta * resource being removed from the stack, showing its logical ID * and CFN type. */ -export function renderOrphanTable(logicalIds: string[], template: CFNTemplate, stackName: string, variant: 'forward' | 'rollback'): string { - const table = new CLITable({ head: ['Logical ID', 'Type'], style: { head: [] } }); +export async function renderOrphanTable( + stackFacade: StackFacade, + logicalIds: string[], + template: CFNTemplate, + stackName: string, + variant: 'forward' | 'rollback', +): Promise { + const deployedResources = await stackFacade.fetchStackResources(stackName); + + const userPool = deployedResources.find((r) => r.ResourceType === USER_POOL_TYPE); + if (!userPool) { + throw new AmplifyError('MigrationError', { message: `Unable to find user pool in stack ${stackName}` }); + } + + const table = new CLITable({ head: ['PhysicalId', 'Logical ID', 'Type'], style: { head: [] } }); for (const id of logicalIds) { - const type = template.Resources[id]?.Type ?? '— (not in template)'; - table.push([id, type]); + const deployedResource = deployedResources.find((r) => r.LogicalResourceId === id); + const templateResource = template.Resources[id]; + if (!deployedResource || !templateResource) { + throw new AmplifyError('MigrationError', { message: `Unable to find resource with id ${id} in stack ${stackName}` }); + } + table.push([`${userPool.PhysicalResourceId}/${deployedResource.PhysicalResourceId}`, id, templateResource.Type]); } const header = variant === 'forward' @@ -173,19 +191,32 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { protected override async beforeMove(gen2StackId: string): Promise { const baseOps = await super.beforeMove(gen2StackId); + const gen2StackName = extractStackNameFromId(gen2StackId); + const holdingStackName = this.getHoldingStackName(gen2StackName); + const holdingStack = await this.cfn.findStack(holdingStackName); + if (holdingStack && !VALID_HOLDING_STACK_STATUSES.includes(holdingStack.StackStatus!)) { + throw new AmplifyError('StackStateError', { + message: `Unexpected state of stack ${holdingStackName}: ${holdingStack.StackStatus} (expected ${VALID_HOLDING_STACK_STATUSES.join( + ', ', + )})`, + }); + } + if (holdingStack) return baseOps; + const template = await this.cfn.fetchTemplate(gen2StackId); const { domainLogicalId, idpLogicalIds } = extractSocialAuthLogicalIds(template); if (domainLogicalId || idpLogicalIds.size > 0) { const socialProvidersResourceIds = [...(domainLogicalId ? [domainLogicalId] : []), ...idpLogicalIds.values()]; const gen2StackName = extractStackNameFromId(gen2StackId); + const description = await renderOrphanTable(this.gen2Branch, socialProvidersResourceIds, template, gen2StackName, 'forward'); baseOps.push({ resource: this.resource, validate: () => ({ description: `Deletion Protection (social auth): ${gen2StackName}`, run: async () => checkRetainPolicies(template, socialProvidersResourceIds), }), - describe: async () => [renderOrphanTable(socialProvidersResourceIds, template, gen2StackName, 'forward')], + describe: async () => [description], execute: () => this.cfn.orphan({ stackName: gen2StackId, @@ -209,6 +240,18 @@ export class AuthCognitoForwardRefactorer extends ForwardCategoryRefactorer { const baseOps = await super.move(blueprint); const gen2StackId = blueprint.targetStackId; + const gen2StackName = extractStackNameFromId(gen2StackId); + const holdingStackName = this.getHoldingStackName(gen2StackName); + const holdingStack = await this.cfn.findStack(holdingStackName); + if (holdingStack && !VALID_HOLDING_STACK_STATUSES.includes(holdingStack.StackStatus!)) { + throw new AmplifyError('StackStateError', { + message: `Unexpected state of stack ${holdingStackName}: ${holdingStack.StackStatus} (expected ${VALID_HOLDING_STACK_STATUSES.join( + ', ', + )})`, + }); + } + if (holdingStack) return baseOps; + const gen1UserPoolId = this.gen1App.resourceMetaOutput(this.resource, 'UserPoolId'); const socialAuthConfig = await this.gen2Branch.fetchSocialAuthConfig(gen1UserPoolId); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts index ac6ee5ea94f..06929dddefe 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/auth/auth-cognito-rollback.ts @@ -4,6 +4,7 @@ import { AmplifyMigrationOperation } from '../../_common/operation'; import { checkRetainPolicies, RefactorBlueprint } from '../workflow/category-refactorer'; import { RollbackCategoryRefactorer } from '../workflow/rollback-category-refactorer'; import { extractStackNameFromId } from '../../_common/utils'; +import { VALID_HOLDING_STACK_STATUSES } from '../../_common/cfn'; import { RESOURCE_TYPES, GEN1_NATIVE_APP_CLIENT, @@ -54,19 +55,33 @@ export class AuthCognitoRollbackRefactorer extends RollbackCategoryRefactorer { const baseOps = await super.move(blueprint); const gen2StackId = blueprint.sourceStackId; + const gen2StackName = extractStackNameFromId(gen2StackId); + const holdingStackName = this.getHoldingStackName(gen2StackName); + const holdingStack = await this.cfn.findStack(holdingStackName); + if (!holdingStack) return baseOps; + + if (!VALID_HOLDING_STACK_STATUSES.includes(holdingStack.StackStatus!)) { + throw new AmplifyError('StackStateError', { + message: `Unexpected state of stack ${holdingStackName}: ${holdingStack.StackStatus} (expected ${VALID_HOLDING_STACK_STATUSES.join( + ', ', + )})`, + }); + } + const template = await this.cfn.fetchTemplate(gen2StackId); const { domainLogicalId, idpLogicalIds } = extractSocialAuthLogicalIds(template); if (domainLogicalId || idpLogicalIds.size > 0) { const socialProvidersResourceIds = [...(domainLogicalId ? [domainLogicalId] : []), ...idpLogicalIds.values()]; const gen2StackName = extractStackNameFromId(gen2StackId); + const description = await renderOrphanTable(this.gen2Branch, socialProvidersResourceIds, template, gen2StackName, 'rollback'); baseOps.push({ resource: this.resource, validate: () => ({ description: `Deletion Protection (social auth): ${gen2StackName}`, run: async () => checkRetainPolicies(template, socialProvidersResourceIds), }), - describe: async () => [renderOrphanTable(socialProvidersResourceIds, template, gen2StackName, 'rollback')], + describe: async () => [description], execute: () => this.cfn.orphan({ stackName: gen2StackId, @@ -95,6 +110,14 @@ export class AuthCognitoRollbackRefactorer extends RollbackCategoryRefactorer { const holdingStack = await this.cfn.findStack(holdingStackName); if (!holdingStack) return baseOps; + if (!VALID_HOLDING_STACK_STATUSES.includes(holdingStack.StackStatus!)) { + throw new AmplifyError('StackStateError', { + message: `Unexpected state of stack ${holdingStackName}: ${holdingStack.StackStatus} (expected ${VALID_HOLDING_STACK_STATUSES.join( + ', ', + )})`, + }); + } + const holdingUserPoolId = await this.gen2Branch.fetchUserPoolId(holdingStackName); if (!holdingUserPoolId) return baseOps; const socialAuthConfig = await this.gen2Branch.fetchSocialAuthConfig(holdingUserPoolId); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts index 5d0b7ef0ce5..72de2cac386 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/forward-category-refactorer.ts @@ -7,6 +7,7 @@ import { resolveOutputs } from '../resolvers/cfn-output-resolver'; import { resolveDependencies } from '../resolvers/cfn-dependency-resolver'; import { resolveConditions } from '../resolvers/cfn-condition-resolver'; import { extractStackNameFromId } from '../../_common/utils'; +import { VALID_HOLDING_STACK_STATUSES } from '../../_common/cfn'; import { CategoryRefactorer, ResolvedStack } from './category-refactorer'; /** @@ -151,6 +152,17 @@ export abstract class ForwardCategoryRefactorer extends CategoryRefactorer { this.debug(`Locating holding stack: ${holdingStackName}`); const holdingStack = await this.cfn.findStack(holdingStackName); + if ( + holdingStack && + holdingStack.StackStatus !== 'REVIEW_IN_PROGRESS' && + !VALID_HOLDING_STACK_STATUSES.includes(holdingStack.StackStatus!) + ) { + throw new AmplifyError('StackStateError', { + message: `Unexpected state of stack ${holdingStackName}: ${holdingStack.StackStatus} (expected ${VALID_HOLDING_STACK_STATUSES.join( + ', ', + )})`, + }); + } const resources = this.filterResourcesByType(gen2StackTemplate); this.debug(`Found ${resources.size} resources to move from stack: ${gen2StackName}`); diff --git a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts index a91fc692f01..aa541b663d4 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/refactor/workflow/rollback-category-refactorer.ts @@ -7,7 +7,7 @@ import { resolveOutputs } from '../resolvers/cfn-output-resolver'; import { resolveDependencies } from '../resolvers/cfn-dependency-resolver'; import { extractStackNameFromId } from '../../_common/utils'; import { CategoryRefactorer, ResolvedStack } from './category-refactorer'; -import { MIGRATION_PLACEHOLDER_LOGICAL_ID } from '../../_common/cfn'; +import { MIGRATION_PLACEHOLDER_LOGICAL_ID, VALID_HOLDING_STACK_STATUSES } from '../../_common/cfn'; /** * Rollback direction base: moves resources from Gen2 (source) back to Gen1 (target). @@ -125,6 +125,14 @@ export abstract class RollbackCategoryRefactorer extends CategoryRefactorer { ]; } + if (!VALID_HOLDING_STACK_STATUSES.includes(holdingStack.StackStatus!)) { + throw new AmplifyError('StackStateError', { + message: `Unexpected state of stack ${holdingStackName}: ${holdingStack.StackStatus} (expected ${VALID_HOLDING_STACK_STATUSES.join( + ', ', + )})`, + }); + } + this.debug(`Fetching template of holding stack: ${holdingStackName}`); const holdingStackTemplate = await this.gen2Branch.fetchTemplate(holdingStackName); const resources = this.filterResourcesByType(holdingStackTemplate); diff --git a/packages/amplify-cli/src/commands/gen2-migration/retain.ts b/packages/amplify-cli/src/commands/gen2-migration/retain.ts index 906c43324fe..1c5f2328c46 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/retain.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/retain.ts @@ -120,6 +120,7 @@ export class AmplifyMigrationRetainStep extends AmplifyMigrationStep { } break; } + case 'custom:customCDK': case 'UNKNOWN': break; } diff --git a/packages/amplify-e2e-gen2-migration/README.md b/packages/amplify-e2e-gen2-migration/README.md index 3d9016d4652..7bd2ba84b1f 100644 --- a/packages/amplify-e2e-gen2-migration/README.md +++ b/packages/amplify-e2e-gen2-migration/README.md @@ -21,7 +21,7 @@ npx tsx src/cli.ts --app project-boards --profile default --verbose | ------------ | ----- | ---------------------------------------------------------------------------------- | | `--app` | `-a` | App to migrate (required). Must match a directory under `amplify-migration-apps/`. | | `--verbose` | `-v` | Enable debug-level logging. | -| `--step` | | Stop at a specific step (`deploy` or `migrate`). Defaults to `migrate`. | +| `--step` | | Stop at a specific step (`deploy` or `migrate`). Defaults to full `e2e` run. | | `--teardown` | | Delete all deployed resources after execution. | ### Credential Refresh @@ -33,72 +33,6 @@ Full migration runs take 30+ minutes, which exceeds typical STS session TTLs. In Because each `refresh()` starts from the long-lived CodeBuild container credentials, the resulting sessions are always fresh regardless of total migration duration. Spawned subprocesses (Amplify CLI, `ampx sandbox`) pick up the refreshed profile via `AWS_PROFILE`. In `--profile` mode, no refresh happens — the caller-supplied profile is assumed to be long-lived. -## Migration Workflow - -The CLI executes the following steps for a given app: - -1. Copy app source to a temp directory (excluding `_snapshot*` and `node_modules`) -2. `amplify init` — initialize the Gen1 project -3. Configure categories by restoring the pre-generate snapshot into the `amplify/` directory -4. `npm install` -5. Run `pre-push` npm script (app-specific fixups before deployment) -6. `amplify push` — deploy the Gen1 stack -7. Run `post-push` npm script (app-specific fixups) -8. Run `test:gen1` — validate the Gen1 deployment -9. `amplify gen2-migration assess` -10. `amplify gen2-migration lock` -11. Checkout a new `gen2-` branch -12. `amplify gen2-migration generate` -13. `npm install` -14. Run `post-generate` npm script (app-specific fixups) -15. `npx ampx sandbox --once` — deploy the Gen2 stack -16. Run `post-sandbox` npm script (app-specific fixups after first sandbox deploy) -17. Run `test:gen1` and `test:gen2` — validate both stacks -18. Checkout `main` branch (refactor requires Gen1 files) -19. `amplify gen2-migration refactor` — move stateful resources to Gen2 -20. Checkout `gen2-` branch -21. Run `post-refactor` npm script (app-specific fixups) -22. Run `test:gen1` and `test:gen2` — validate both stacks -23. Redeploy Gen2 sandbox to pick up post-refactor changes -24. Run `test:gen1` and `test:gen2` — final validation -25. Run shared data tests -26. `amplify gen2-migration retain` — apply retain policies to every resource below root -27. Run `test:gen1` and `test:gen2` — post-retain validation - -Test scripts run at multiple points to verify that both stacks remain functional throughout the migration. - -The system runs npm scripts defined in each app's `package.json`: - -- `pre-push` — before `amplify push` -- `post-push` — after `amplify push` -- `post-generate` — after `gen2-migration generate` -- `post-sandbox` — after the first `npx ampx sandbox --once` deploy -- `post-refactor` — after `gen2-migration refactor` -- `test:gen1` — Jest tests against the Gen1 config (`src/amplifyconfiguration.json`) -- `test:gen2` — Jest tests against the Gen2 config (`amplify_outputs.json`) - -Scripts set to `"true"` in `package.json` are effectively no-ops. - -### Migration Config - -Each app can optionally include a `migration/config.json` to customize the E2E workflow: - -```json -{ - "lock": { "skipValidations": true } -} -``` - -| Field | Description | -| -------------------------- | ------------------------------------------------------- | -| `lock.skipValidations` | Pass `--skip-validations` to `gen2-migration lock`. | -| `refactor.skip` | Skip the refactor step entirely. | -| `refactor.skipValidations` | Pass `--skip-validations` to `gen2-migration refactor`. | - -If the file does not exist, defaults are used. - -For details on the app layout, test scripts, and migration scripts, see the [amplify-migration-apps README](../../amplify-migration-apps/README.md). - ## Package Architecture ``` diff --git a/packages/amplify-e2e-gen2-migration/src/cli.ts b/packages/amplify-e2e-gen2-migration/src/cli.ts index 84f1fe0aaf2..4691126a7ca 100644 --- a/packages/amplify-e2e-gen2-migration/src/cli.ts +++ b/packages/amplify-e2e-gen2-migration/src/cli.ts @@ -4,7 +4,6 @@ import * as yargs from 'yargs'; import chalk from 'chalk'; import { App } from './core/app'; -import { Teardown } from './core/teardown'; import { resolveProfile } from './core/credentials'; async function main(): Promise { @@ -53,34 +52,22 @@ async function main(): Promise { process.exit(1); } - const step = argv.step ?? 'migrate'; + const step = argv.step ?? 'e2e'; const profile = resolveProfile(argv.profile); const app = new App(argv.app, profile, argv.verbose); - try { - switch (step) { - case 'deploy': - await app.deploy(); - break; - case 'migrate': - await app.migrate(); - break; - default: - throw new Error(`Unrecognized step: ${step}`); - } - if (process.env.UPDATE_SNAPSHOTS === '1') { - app.updateSnapshots(); - } - app.logger.info(`Execution completed successfully (${app.targetAppPath})`); - } catch (error) { - console.log(); - (error as Error).message = `Execution failed: ${chalk.red((error as Error).message)}\n\n(App path: ${app.targetAppPath})\n`; - throw error; - } finally { - if (argv.teardown) { - await app.refreshCredentials(); - await new Teardown(app.deploymentName, app.getClientConfig()).clean(); - } + switch (step) { + case 'deploy': + await app.deploy(); + break; + case 'migrate': + await app.migrate(); + break; + case 'e2e': + await app.e2e({ teardown: argv.teardown ?? false }); + break; + default: + throw new Error(`Unrecognized step: ${step}`); } } diff --git a/packages/amplify-e2e-gen2-migration/src/core/app.ts b/packages/amplify-e2e-gen2-migration/src/core/app.ts index 916943ac223..77aacd7c0e2 100644 --- a/packages/amplify-e2e-gen2-migration/src/core/app.ts +++ b/packages/amplify-e2e-gen2-migration/src/core/app.ts @@ -17,20 +17,26 @@ import { sanitize } from './sanitize'; import { normalize } from './normalize'; import { CredentialManager } from './credentials'; import { CloudFormationClient, paginateListStacks, StackStatus } from '@aws-sdk/client-cloudformation'; +import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; import { AmplifyClient } from '@aws-sdk/client-amplify'; import { fromIni } from '@aws-sdk/credential-providers'; +import type { AwsCredentialIdentity } from '@aws-sdk/types'; +import { Teardown } from './teardown'; +const REPO_ROOT_DIR = process.env.CODEBUILD_SRC_DIR ?? path.join(__dirname, '..', '..', '..', '..'); const MIGRATION_TARGET_DIR = path.join(os.tmpdir(), 'amplify-e2e-gen2-migration', 'output-apps'); const MIGRATION_SNAPSHOT_DIR = path.join(os.tmpdir(), 'amplify-e2e-gen2-migration', 'snapshots'); -const MIGRATION_APPS_DIR = path.join(__dirname, '..', '..', '..', '..', 'amplify-migration-apps'); +const MIGRATION_APPS_DIR = path.join(REPO_ROOT_DIR, 'amplify-migration-apps'); interface MigrationConfig { /** * Per-step configuration overrides. */ - readonly lock?: StepConfig; + readonly lockForward?: StepConfig; + readonly lockRollback?: StepConfig; + readonly refactorForward?: StepConfig; + readonly refactorRollback?: StepConfig; readonly generate?: StepConfig; - readonly refactor?: RefactorConfig; } interface StepConfig { @@ -38,17 +44,19 @@ interface StepConfig { * Pass --skip-validations to the step. */ readonly skipValidations?: boolean; -} -interface RefactorConfig { /** - * Skip the refactor step entirely (e.g., when a sub-feature breaks refactoring). + * Skip the step entirely. */ readonly skip?: boolean; - /** - * Pass --skip-validations to the refactor step. - */ - readonly skipValidations?: boolean; +} + +interface E2EOptions { + readonly teardown: boolean; +} + +interface ForwardOptions { + readonly lock: boolean; } /** @@ -69,7 +77,7 @@ export class App { * Whether the refactor step should be skipped entirely for this app. */ public get skipRefactor(): boolean { - return this.migrationConfig.refactor?.skip === true; + return this.migrationConfig.refactorForward?.skip === true; } private readonly amplifyPath: string; private readonly credentials: CredentialManager; @@ -101,6 +109,7 @@ export class App { const region = process.env.CLI_REGION ?? process.env.AWS_REGION ?? 'us-east-1'; const generatedProfile = `amplify-migration-e2e-${this.deploymentName}`; + this.credentials = new CredentialManager(profile, region, generatedProfile, this.logger); // temporary directory to store snapshot of each step @@ -223,8 +232,99 @@ export class App { * Run `amplify push --yes`. */ public async push(): Promise { - await this.refreshCredentials(); - await this.runAmplify(['push', '--yes', '--debug']); + await this.runAmplify(['push', '--force', '--yes']); + } + + /** + * Run a full E2E migration test on this app. + */ + public async e2e(options: E2EOptions): Promise { + this.logger.info(`Started e2e execution`); + try { + printPhaseBanner(`Phase 1 | Migration`); + const gen2StackName = await this.migrate(); + + if (!this.skipRefactor) { + printPhaseBanner(`Phase 2 | Post Migration | Rollback`); + await this.rollback(gen2StackName); + + printPhaseBanner(`Phase 3 | Post Migration | Forward`); + // lock: true because we rolled back + await this.forward(gen2StackName, { lock: true }); + } + + printPhaseBanner(`Phase 4 | Post Migration | Retain`); + await this.git.checkout(this.gen1BranchName, false); + await this.pull(); + await this.retain(); + + await this.testGen1(); + await this.testGen2(); + + this.logger.info(`Execution completed successfully (${this.targetAppPath})`); + if (process.env.UPDATE_SNAPSHOTS === '1') { + this.updateSnapshots(); + } + } catch (error) { + console.log(); + (error as Error).message = `Migration failed: ${(error as Error).message}\n\n(App path: ${this.targetAppPath})\n`; + console.log((error as Error).stack); + console.log(); + throw error; + } finally { + if (options.teardown) { + await this.refreshCredentials(); + const teardown = new Teardown(this.deploymentName, this.getClientConfig()); + await teardown.clean(); + } + } + } + + public async rollback(gen2StackName: string): Promise { + await this.git.checkout(this.gen1BranchName, false); + await this.pull(); + await this.refactorRollback(gen2StackName); + await this.lockRollback(); + await this.push(); + + await this.testGen1(); + + await this.git.checkout(this.gen2BranchName, false); + await this.postRollback(); + await this.git.diff(); + await this.git.commit('chore: post rollback'); + await this.deployGen2Sandbox(); + + await this.testGen1(); + await this.testGen2(); + } + + public async forward(gen2StackName: string, options: ForwardOptions): Promise { + await this.git.checkout(this.gen1BranchName, false); + await this.pull(); + if (options.lock) { + await this.lockForward(); + } + await this.refactorForward(gen2StackName); + + this.logger.info(`Capturing post.refactor snapshot`); + console.log(''); + await snapshot.capturePostRefactor(this.targetAppPath, this.snapshotAppPath); + console.log(''); + + await this.testGen1(); + await this.testGen2(); + + await this.git.checkout(this.gen2BranchName, false); + await this.postRefactor(); + await this.git.diff(); + await this.git.commit('chore: post refactor'); + await this.deployGen2Sandbox(); + + await this.testGen1(); + await this.testGen2(); + + await this.testShared(); } /** @@ -254,10 +354,10 @@ export class App { /** * Runs the full migration workflow */ - public async migrate(): Promise { + public async migrate(): Promise { await this.deploy(); await this.assess(); - await this.lock(); + await this.lockForward(); await this.testGen1(); @@ -291,37 +391,13 @@ export class App { if (this.skipRefactor) { this.logger.info('Skipping refactor (configured in migration/config.json)'); - return; + return gen2StackName; } - await this.git.checkout(this.gen1BranchName, false); - await this.pull(); - await this.refactor(gen2StackName); + // lock: false because we already executed lock + await this.forward(gen2StackName, { lock: false }); - this.logger.info(`Capturing post.refactor snapshot`); - console.log(''); - await snapshot.capturePostRefactor(this.targetAppPath, this.snapshotAppPath); - console.log(''); - - await this.testGen1(); - await this.testGen2(); - - await this.git.checkout(this.gen2BranchName, false); - await this.postRefactor(); - await this.git.diff(); - await this.git.commit('chore: post refactor'); - - await this.deployGen2Sandbox(); - - await this.testGen1(); - await this.testGen2(); - - await this.testShared(); - - await this.retain(); - - await this.testGen1(); - await this.testGen2(); + return gen2StackName; } /** @@ -335,9 +411,24 @@ export class App { /** * Run `amplify gen2-migration lock`. */ - public async lock(): Promise { + public async lockForward(): Promise { + await this.refreshCredentials(); + const extraArgs: string[] = []; + if (this.migrationConfig.lockForward?.skipValidations) { + extraArgs.push('--skip-validations'); + } + await this.runMigrationStep('lock', extraArgs); + } + + /** + * Run `amplify gen2-migration lock --rollback`. + */ + public async lockRollback(): Promise { await this.refreshCredentials(); - const extraArgs = this.migrationConfig.lock?.skipValidations ? ['--skip-validations'] : []; + const extraArgs = ['--rollback']; + if (this.migrationConfig.lockRollback?.skipValidations) { + extraArgs.push('--skip-validations'); + } await this.runMigrationStep('lock', extraArgs); } @@ -354,12 +445,40 @@ export class App { /** * Run `amplify gen2-migration refactor`. */ - public async refactor(gen2StackName: string): Promise { + public async refactorForward(gen2StackName: string): Promise { await this.refreshCredentials(); const extraArgs = ['--to', gen2StackName]; - if (this.migrationConfig.refactor?.skipValidations) { + if (this.migrationConfig.refactorForward?.skipValidations) { extraArgs.push('--skip-validations'); } + + // twice for idempotancy. print a banner so its easier to distinguish + // the different runs in the logs. + + printStepBanner('Forward Refactor (1)'); + await this.runMigrationStep('refactor', extraArgs); + + printStepBanner('Forward Refactor (2)'); + await this.runMigrationStep('refactor', extraArgs); + } + + /** + * Run `amplify gen2-migration refactor --rollback`. + */ + public async refactorRollback(gen2StackName: string): Promise { + await this.refreshCredentials(); + const extraArgs = ['--to', gen2StackName, '--rollback']; + if (this.migrationConfig.refactorRollback?.skipValidations) { + extraArgs.push('--skip-validations'); + } + + // twice for idempotancy. print a banner so its easier to distinguish + // the different runs in the logs. + + printStepBanner('Rollback Refactor (1)'); + await this.runMigrationStep('refactor', extraArgs); + + printStepBanner('Rollback Refactor (2)'); await this.runMigrationStep('refactor', extraArgs); } @@ -386,6 +505,7 @@ export class App { reject: false, stdio: 'inherit', env: this.getEnv({ AWS_BRANCH: this.gen2BranchName }), + extendEnv: false, }); if (result.exitCode !== 0) { @@ -465,6 +585,13 @@ export class App { await this.runNpmScript('post-refactor'); } + /** + * Run the post-rollback script. + */ + public async postRollback(): Promise { + await this.runNpmScript('post-rollback'); + } + /** * Run the post-sandbox script with the Gen2 root stack name. */ @@ -508,8 +635,8 @@ export class App { * Refresh credentials. Call before any AWS operation in role mode so the * underlying profile doesn't expire mid-step. No-op in profile mode. */ - public async refreshCredentials(): Promise { - await this.credentials.refresh(); + public async refreshCredentials(): Promise { + return await this.credentials.refresh(); } /** @@ -534,7 +661,7 @@ export class App { * chain, which may prefer container/IMDS credentials in CI environments. */ public getClientConfig(): { credentials: ReturnType } { - return { credentials: fromIni({ profile: this.profile }) }; + return { credentials: fromIni({ profile: this.profile, ignoreCache: true }) }; } // ============================================================ @@ -549,19 +676,22 @@ export class App { const region = process.env.CLI_REGION ?? 'us-east-1'; this.logger.info(`Bootstrapping CDK for region ${region}...`); - const identity = await execa('aws', ['sts', 'get-caller-identity', '--query', 'Account', '--output', 'text'], { - env: this.getEnv(), - }); - const accountId = identity.stdout.trim(); + const stsClient = new STSClient({ ...this.getClientConfig(), region }); + const identity = await stsClient.send(new GetCallerIdentityCommand({})); + const accountId = identity.Account; + if (!accountId) { + throw new Error('Unable to determine AWS account ID from STS.'); + } const result = await execa('npx', ['cdk', 'bootstrap', `aws://${accountId}/${region}`], { cwd: this.targetAppPath, reject: false, stdio: 'inherit', env: this.getEnv(), + extendEnv: false, }); if (result.exitCode !== 0) { - this.logger.info('CDK bootstrap may already exist or failed, continuing...'); + throw new Error(`'cdk bootstrap' failed. See above logs for details.`); } } private removeGitignoreLine(line: string): void { @@ -582,6 +712,7 @@ export class App { } private async runAmplify(args: string[], options?: { stdio?: 'inherit' }): Promise { + await this.refreshCredentials(); const originalCwd = process.cwd(); process.chdir(this.targetAppPath); try { @@ -596,6 +727,7 @@ export class App { cwd: this.targetAppPath, stdio: options?.stdio, env: this.getEnv(), + extendEnv: false, }); if (result.exitCode !== 0) { throw new Error(`${command} failed with exit code ${result.exitCode}`); @@ -621,6 +753,7 @@ export class App { stdio: 'inherit', reject: false, env: this.getEnv(), + extendEnv: false, }); if (result.exitCode !== 0) { @@ -635,11 +768,13 @@ export class App { * Silently skips if the script is not defined. */ private async runNpmScript(scriptName: string, extraEnv?: Record): Promise { + await this.refreshCredentials(); const result = await execa('npm', ['run', scriptName], { cwd: this.targetAppPath, stdio: 'inherit', reject: false, env: this.getEnv({ GEN1_ENV_NAME: this.envName, AWS_SDK_LOAD_CONFIG: '1', ...extraEnv }), + extendEnv: false, }); if (result.exitCode !== 0) { @@ -666,7 +801,7 @@ export class App { '--output', 'text', ], - { reject: false, env: this.getEnv() }, + { reject: false, env: this.getEnv(), extendEnv: false }, ); if (result.exitCode !== 0) { @@ -688,6 +823,44 @@ export class App { } } +/** + * Prints a centered emphasized phase banner to stdout for visual separation in logs. + * + * ╔════════════════════════════════════════════════════════════╗ + * ║ ║ + * ║ ${text} ║ + * ║ ║ + * ╚════════════════════════════════════════════════════════════╝ + * + */ +function printPhaseBanner(text: string): void { + const innerWidth = 60; + const padding = Math.max(0, innerWidth - text.length); + const leftPad = Math.floor(padding / 2); + const rightPad = padding - leftPad; + const border = '\u2550'.repeat(innerWidth); + const emptyLine = `\u2551${' '.repeat(innerWidth)}\u2551`; + const textLine = `\u2551${' '.repeat(leftPad)}${text}${' '.repeat(rightPad)}\u2551`; + console.log(`\n\u2554${border}\u2557\n${emptyLine}\n${textLine}\n${emptyLine}\n\u255A${border}\u255D\n`); +} + +/** + * Prints a smaller step banner for sub-steps within a phase. + * + * +---------------------------+ + * | {text} | + * +---------------------------+ + */ +function printStepBanner(text: string): void { + const innerWidth = 40; + const padding = Math.max(0, innerWidth - text.length); + const leftPad = Math.floor(padding / 2); + const rightPad = padding - leftPad; + const border = '-'.repeat(innerWidth); + const textLine = `|${' '.repeat(leftPad)}${text}${' '.repeat(rightPad)}|`; + console.log(`\n+${border}+\n${textLine}\n+${border}+\n`); +} + /** * Generates a time-based Amplify app name. * Format: [prefix][YYMMDDHHMM] (20 chars max for Amplify compatibility). diff --git a/packages/amplify-e2e-gen2-migration/src/core/credentials.ts b/packages/amplify-e2e-gen2-migration/src/core/credentials.ts index 0def2bc5318..3c85235be80 100644 --- a/packages/amplify-e2e-gen2-migration/src/core/credentials.ts +++ b/packages/amplify-e2e-gen2-migration/src/core/credentials.ts @@ -6,6 +6,7 @@ import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; import { fromContainerMetadata } from '@aws-sdk/credential-providers'; import { Logger } from './logger'; import { mergeManagedSection } from './ini-merge'; +import type { AwsCredentialIdentity } from '@aws-sdk/types'; /** Duration for assumed-role sessions (1 hour — STS maximum for chained roles). */ const SESSION_DURATION_SECONDS = 3600; @@ -60,7 +61,7 @@ export class CredentialManager { } /** Whether this manager operates in CI mode. */ - private get isCIMode(): boolean { + public get isCIMode(): boolean { return this.callerProfile === undefined; } @@ -82,9 +83,9 @@ export class CredentialManager { * resulting sessions are always fresh regardless of how long the * migration has been running. */ - public async refresh(): Promise { + public async refresh(): Promise { if (!this.isCIMode) { - return; + return undefined; } if (!this.parentRoleArn) { throw new Error('TEST_ACCOUNT_ROLE must be set in CI mode'); @@ -142,6 +143,8 @@ export class CredentialManager { this.writeCredentialsFile(childCreds.AccessKeyId, childCreds.SecretAccessKey, childCreds.SessionToken); this.logger.info('Credentials refreshed'); + + return { accessKeyId: childCreds.AccessKeyId, secretAccessKey: childCreds.SecretAccessKey, sessionToken: childCreds.SessionToken }; } private writeCredentialsFile(accessKeyId: string, secretAccessKey: string, sessionToken: string): void { diff --git a/packages/amplify-e2e-gen2-migration/src/core/teardown.ts b/packages/amplify-e2e-gen2-migration/src/core/teardown.ts index d4cb84232e9..ad01eafc7a4 100644 --- a/packages/amplify-e2e-gen2-migration/src/core/teardown.ts +++ b/packages/amplify-e2e-gen2-migration/src/core/teardown.ts @@ -12,8 +12,8 @@ import { import { AmplifyClient, ListAppsCommand, DeleteAppCommand } from '@aws-sdk/client-amplify'; import { DynamoDBClient, UpdateTableCommand } from '@aws-sdk/client-dynamodb'; import { S3Client, paginateListObjectsV2, DeleteObjectsCommand } from '@aws-sdk/client-s3'; -import { fromIni } from '@aws-sdk/credential-providers'; import { Logger } from './logger'; +import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from '@aws-sdk/types'; /** Maximum number of discover-and-delete passes for stuck stacks. */ const MAX_DELETE_PASSES = 5; @@ -33,7 +33,7 @@ const DELETE_POLL_INTERVAL_SECONDS = 10; export class Teardown { private readonly deploymentName: string; private readonly logger: Logger; - private readonly clientConfig: { credentials: ReturnType }; + private readonly clientConfig: { credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider }; private readonly errors: string[] = []; /** @@ -41,7 +41,7 @@ export class Teardown { * @param clientConfig SDK client config with a credentials provider. * Typically obtained from `App.getClientConfig()`. */ - constructor(deploymentName: string, clientConfig: { credentials: ReturnType }) { + constructor(deploymentName: string, clientConfig: { credentials: AwsCredentialIdentity | AwsCredentialIdentityProvider }) { this.deploymentName = deploymentName; this.logger = new Logger(`teardown-${deploymentName}`); this.clientConfig = clientConfig; diff --git a/packages/amplify-e2e-tests/Readme.md b/packages/amplify-e2e-tests/Readme.md index eb780cc7401..d1f20198ae9 100644 --- a/packages/amplify-e2e-tests/Readme.md +++ b/packages/amplify-e2e-tests/Readme.md @@ -73,3 +73,23 @@ There are two scenarios when this approach can cause trouble: 2. In the middle of execution, the test is interrupted by Ctrl+C, then the hidden config and credential files are not renamed back. So, You should NOT run multiple tests in parallel locally with the `init-special-case` test included. And, if you use Ctrl+C to interrupt the `init-special-case` test, you need to go to the `~/.aws/c` folder and rename the config and credential files to their original names. + +## Gen2 Migration E2E Tests + +The tests under `src/__tests__/gen2-migration/` exercise the full Gen1-to-Gen2 migration workflow for each sample app. They delegate entirely to the `@aws-amplify/amplify-e2e-gen2-migration` package — each test file simply calls `runMigrationE2E('')` which constructs an `App` instance and runs the complete lifecycle in-process. + +Each test passes `teardown: true`, so all deployed resources (Gen1 stacks, Gen2 sandbox stacks, Amplify apps) are cleaned up automatically at the end of the run, regardless of success or failure. + +### Duration + +These tests are long — typically around two hours per app. Each test runs the full migration end-to-end (deploy Gen1, generate, sandbox, refactor) and then rolls back and re-migrates forward to verify rollback safety. This effectively exercises the entire workflow twice. Most of the wall-clock time is spent waiting on CloudFormation stack operations. + +### Retries + +The standard `retry` function in `local_publish_helpers_codebuild.sh` (which retries a test command up to 2 times) does not work for these tests. A single run can exceed one hour, and by the time the retry starts, the AWS session token obtained at the beginning has expired. The tests themselves handle credential refresh internally via `CredentialManager`, but that only works within a single continuous execution — a fresh retry starts from scratch with stale credentials. + +Retries must be done externally via CodeBuild. When a test fails, the entire CodeBuild build is retried from the beginning, which obtains fresh credentials and starts a clean run. + +### Known Flaky Test + +The `mood-board` test is flaky due to a race condition in its Kinesis-backed frontend test (write/read timing). If it fails, retry the test — the underlying migration logic is correct. diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-backend-only.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-backend-only.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-backend-only.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-backend-only.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-discussions.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-discussions.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-discussions.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-discussions.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-finance-tracker.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-finance-tracker.test.ts new file mode 100644 index 00000000000..8f4395d6cea --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-finance-tracker.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - finance-tracker', () => { + it( + 'migrates the finance-tracker app from Gen1 to Gen2', + async () => { + await runMigrationE2E('finance-tracker'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-fitness-tracker.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-fitness-tracker.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-fitness-tracker.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-fitness-tracker.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-media-vault.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-media-vault.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-media-vault.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-media-vault.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-mood-board.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-mood-board.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-mood-board.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-mood-board.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-product-catalog.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-product-catalog.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-product-catalog.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-product-catalog.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-project-boards.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-project-boards.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-project-boards.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-project-boards.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-store-locator.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-store-locator.test.ts similarity index 100% rename from packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-store-locator.test.ts rename to packages/amplify-e2e-tests/src/__tests__/gen2-migration/gen2-migration-store-locator.test.ts diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/run-migration-e2e.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/run-migration-e2e.ts index a53cc99814f..34ab4ce5879 100644 --- a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/run-migration-e2e.ts +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/run-migration-e2e.ts @@ -1,13 +1,13 @@ /* eslint-disable spellcheck/spell-checker */ import { execSync } from 'child_process'; import { STSClient, GetCallerIdentityCommand } from '@aws-sdk/client-sts'; -import { App, Teardown } from '@aws-amplify/amplify-e2e-gen2-migration'; +import { App } from '@aws-amplify/amplify-e2e-gen2-migration'; /** - * Jest timeout for migration tests (2 hours). Migration runs involve - * full Gen1 push + Gen2 sandbox deploy and can take 30–90 minutes. + * Jest timeout for migration tests (3 hours). Migration runs involve + * full Gen1 push + Refactor + Gen2 sandbox deploy several times. */ -export const MIGRATION_TEST_TIMEOUT_MS = 2 * 60 * 60 * 1000; +export const MIGRATION_TEST_TIMEOUT_MS = 3 * 60 * 60 * 1000; /** * Resolve the child account ID from the current STS caller identity. @@ -37,6 +37,14 @@ async function resolveChildAccountId(): Promise { * in CI mode (two-hop assume-role from container credentials). */ export async function runMigrationE2E(appName: string): Promise { + // the default jest console logger adds a noisy call-site logging + // statement. in our case since we wrap console.log with a Logger, all these + // call-sites are the same and are not helpful. + // restore the standard console logger so the output looks like a regular process + // execution. + const { Console } = require('console'); + global.console = new Console(process.stdout, process.stderr); + // Resolve the child account from the shell-level credentials. // This must be the same account that amplify init/push deploy to. const childAccountId = await resolveChildAccountId(); @@ -50,18 +58,5 @@ export async function runMigrationE2E(appName: string): Promise { // CredentialManager. The CredentialManager uses fromContainerMetadata() // explicitly, so it works even with child account creds in process.env. const app = new App(appName, undefined); - try { - await app.migrate(); - if (process.env.UPDATE_SNAPSHOTS === '1') { - app.updateSnapshots(); - } - } finally { - try { - await app.refreshCredentials(); - await new Teardown(app.deploymentName, app.getClientConfig()).clean(); - } catch (teardownError) { - // Teardown failures should not mask a successful migration. - console.error('Teardown failed (non-fatal):', (teardownError as Error).message); - } - } + await app.e2e({ teardown: true }); } diff --git a/scripts/split-e2e-tests-codebuild.ts b/scripts/split-e2e-tests-codebuild.ts index 47eaee5d7b9..a8fef5035b7 100644 --- a/scripts/split-e2e-tests-codebuild.ts +++ b/scripts/split-e2e-tests-codebuild.ts @@ -60,14 +60,15 @@ const DISABLE_COVERAGE = [ 'src/__tests__/datastore-modelgen.test.ts', 'src/__tests__/amplify-app.test.ts', 'src/__tests__/smoke-tests/smoketest-amplify-app.test.ts', - 'src/__tests__/gen2-migration/migrate-backend-only.test.ts', - 'src/__tests__/gen2-migration/migrate-discussions.test.ts', - 'src/__tests__/gen2-migration/migrate-fitness-tracker.test.ts', - 'src/__tests__/gen2-migration/migrate-media-vault.test.ts', - 'src/__tests__/gen2-migration/migrate-mood-board.test.ts', - 'src/__tests__/gen2-migration/migrate-product-catalog.test.ts', - 'src/__tests__/gen2-migration/migrate-project-boards.test.ts', - 'src/__tests__/gen2-migration/migrate-store-locator.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-backend-only.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-discussions.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-fitness-tracker.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-finance-tracker.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-media-vault.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-mood-board.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-product-catalog.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-project-boards.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-store-locator.test.ts', ]; const TEST_EXCLUSIONS: { l: string[]; w: string[] } = { l: [], @@ -136,15 +137,17 @@ const TEST_EXCLUSIONS: { l: string[]; w: string[] } = { 'src/__tests__/pinpoint/javascript-analytics-pinpoint-config.test.ts', 'src/__tests__/pinpoint/javascript-notifications-pinpoint-config.test.ts', 'src/__tests__/pinpoint/notifications-pinpoint-config-util.ts', - // gen2-migration tests are not supported on Windows - 'src/__tests__/gen2-migration/migrate-backend-only.test.ts', - 'src/__tests__/gen2-migration/migrate-discussions.test.ts', - 'src/__tests__/gen2-migration/migrate-fitness-tracker.test.ts', - 'src/__tests__/gen2-migration/migrate-media-vault.test.ts', - 'src/__tests__/gen2-migration/migrate-mood-board.test.ts', - 'src/__tests__/gen2-migration/migrate-product-catalog.test.ts', - 'src/__tests__/gen2-migration/migrate-project-boards.test.ts', - 'src/__tests__/gen2-migration/migrate-store-locator.test.ts', + // gen2-migration tests dont currently work on windows because of 'Could not resolve credentials using profile'. + // need to dive deeper to figure it out. + 'src/__tests__/gen2-migration/gen2-migration-backend-only.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-discussions.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-fitness-tracker.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-finance-tracker.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-media-vault.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-mood-board.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-product-catalog.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-project-boards.test.ts', + 'src/__tests__/gen2-migration/gen2-migration-store-locator.test.ts', ], }; export function loadConfigBase() {