diff --git a/amplify-migration-apps/teardown.ts b/amplify-migration-apps/teardown.ts index a010c3bf850..dbfe7c3ce64 100644 --- a/amplify-migration-apps/teardown.ts +++ b/amplify-migration-apps/teardown.ts @@ -1,4 +1,5 @@ import { Teardown } from '../packages/amplify-e2e-gen2-migration/src/core/teardown'; +import { fromIni } from '@aws-sdk/credential-providers'; const deploymentName = process.argv[2]; const profile = process.argv[3]; @@ -8,7 +9,7 @@ if (!deploymentName || !profile) { process.exit(1); } -new Teardown(deploymentName, profile).clean().catch((e: Error) => { +new Teardown(deploymentName, { credentials: fromIni({ profile }) }).clean().catch((e: Error) => { console.error(e.message); process.exit(1); }); diff --git a/codebuild_specs/e2e_workflow_base.yml b/codebuild_specs/e2e_workflow_base.yml index 32cfb1748fa..756a6e27737 100644 --- a/codebuild_specs/e2e_workflow_base.yml +++ b/codebuild_specs/e2e_workflow_base.yml @@ -140,75 +140,3 @@ batch: buildspec: codebuild_specs/cleanup_resources.yml depend-on: - aggregate_e2e_reports - - migrate_backend_only - - migrate_discussions - - migrate_fitness_tracker - - migrate_media_vault - - migrate_mood_board - - migrate_product_catalog - - migrate_project_boards - - migrate_store_locator - - identifier: migrate_backend_only - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: backend-only - - identifier: migrate_discussions - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: discussions - - identifier: migrate_fitness_tracker - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: fitness-tracker - - identifier: migrate_media_vault - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: media-vault - - identifier: migrate_mood_board - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: mood-board - - identifier: migrate_product_catalog - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: product-catalog - - identifier: migrate_project_boards - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: project-boards - - identifier: migrate_store_locator - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: store-locator diff --git a/codebuild_specs/e2e_workflow_generated.yml b/codebuild_specs/e2e_workflow_generated.yml index c84ef3e5607..09e35bed9f7 100644 --- a/codebuild_specs/e2e_workflow_generated.yml +++ b/codebuild_specs/e2e_workflow_generated.yml @@ -140,78 +140,6 @@ batch: buildspec: codebuild_specs/cleanup_resources.yml depend-on: - aggregate_e2e_reports - - migrate_backend_only - - migrate_discussions - - migrate_fitness_tracker - - migrate_media_vault - - migrate_mood_board - - migrate_product_catalog - - migrate_project_boards - - migrate_store_locator - - identifier: migrate_backend_only - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: backend-only - - identifier: migrate_discussions - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: discussions - - identifier: migrate_fitness_tracker - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: fitness-tracker - - identifier: migrate_media_vault - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: media-vault - - identifier: migrate_mood_board - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: mood-board - - identifier: migrate_product_catalog - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: product-catalog - - identifier: migrate_project_boards - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: project-boards - - identifier: migrate_store_locator - buildspec: codebuild_specs/run_gen2_migration_e2e.yml - depend-on: - - upb - env: - compute-type: BUILD_GENERAL1_LARGE - variables: - MIGRATION_APP: store-locator - identifier: l_diagnose_mock_api_hooks_a buildspec: codebuild_specs/run_e2e_tests_linux.yml env: @@ -1039,6 +967,78 @@ batch: DISABLE_COVERAGE: 1 depend-on: - upb + - identifier: l_migrate_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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_discussions + 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 + DISABLE_COVERAGE: 1 + depend-on: + - upb + - identifier: l_migrate_backend_only + 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 + DISABLE_COVERAGE: 1 + depend-on: + - upb - identifier: l_js_frontend_config buildspec: codebuild_specs/run_e2e_tests_linux.yml env: diff --git a/codebuild_specs/run_gen2_migration_e2e.yml b/codebuild_specs/run_gen2_migration_e2e.yml deleted file mode 100644 index b3e380d5e1d..00000000000 --- a/codebuild_specs/run_gen2_migration_e2e.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: 0.2 -env: - shell: bash - variables: - CI: true - IS_AMPLIFY_CI: true - TEARDOWN: '1' - -phases: - build: - commands: - - export NODE_OPTIONS=--max-old-space-size=4096 - - export AMPLIFY_DIR=$CODEBUILD_SRC_DIR/out - - export AMPLIFY_PATH=$CODEBUILD_SRC_DIR/out/amplify-pkg-linux-x64 - - echo "AMPLIFY_DIR=$AMPLIFY_DIR" - - echo "AMPLIFY_PATH=$AMPLIFY_PATH" - - echo "MIGRATION_APP=$MIGRATION_APP" - - source ./shared-scripts.sh && _runGen2MigrationE2E - - post_build: - commands: - - echo "Migration E2E test completed for $MIGRATION_APP" diff --git a/codebuild_specs/wait_for_ids.json b/codebuild_specs/wait_for_ids.json index 9728f2e0c3f..4cf2609e142 100644 --- a/codebuild_specs/wait_for_ids.json +++ b/codebuild_specs/wait_for_ids.json @@ -83,6 +83,14 @@ "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-e2e-core/src/init/amplifyPush.ts b/packages/amplify-e2e-core/src/init/amplifyPush.ts index 699091354e6..5e295b4b6eb 100644 --- a/packages/amplify-e2e-core/src/init/amplifyPush.ts +++ b/packages/amplify-e2e-core/src/init/amplifyPush.ts @@ -128,7 +128,7 @@ export function cancelIterativeAmplifyPush( .wait(`Deploying iterative update ${idx.current} of ${idx.max} into`) .wait(/.*AWS::AppSync::GraphQLSchema\s*UPDATE_IN_PROGRESS.*/) .sendCtrlC() - .runAsync((err: Error) => err.message === 'Process exited with non zero exit code 130'); + .runAsync((err: Error) => err.message.startsWith('Process exited with non zero exit code 130')); } /** diff --git a/packages/amplify-e2e-gen2-migration/README.md b/packages/amplify-e2e-gen2-migration/README.md index b1fbcedc395..d40593310a6 100644 --- a/packages/amplify-e2e-gen2-migration/README.md +++ b/packages/amplify-e2e-gen2-migration/README.md @@ -26,7 +26,12 @@ npx tsx src/cli.ts --app project-boards --profile default --verbose ### Credential Refresh -Full migration runs take 30+ minutes, which exceeds typical STS session TTLs. When `TEST_ACCOUNT_ROLE` is used, the CLI re-assumes the role and rewrites `~/.aws/credentials` before every long-running step (`init`, `push`, `assess`, `lock`, `generate`, `refactor`, `deployGen2Sandbox`, `teardown`) so sessions don't expire mid-operation. 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. +Full migration runs take 30+ minutes, which exceeds typical STS session TTLs. In CI, `CredentialManager` performs a two-hop assume-role chain on each `refresh()` call: + +1. CodeBuild container credentials (long-lived) → assume `TEST_ACCOUNT_ROLE` (parent account, 1hr) +2. Parent account credentials → assume `OrganizationAccountAccessRole` in `CHILD_ACCOUNT_ID` (1hr) + +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 diff --git a/packages/amplify-e2e-gen2-migration/package.json b/packages/amplify-e2e-gen2-migration/package.json index 14b896e1fd7..404282a604f 100644 --- a/packages/amplify-e2e-gen2-migration/package.json +++ b/packages/amplify-e2e-gen2-migration/package.json @@ -3,9 +3,9 @@ "private": true, "version": "1.0.1", "description": "Migration automation system for AWS Amplify Gen1 to Gen2", - "main": "dist/index.js", + "main": "lib/index.js", "bin": { - "amplify-migrate": "dist/cli.js" + "amplify-migrate": "lib/cli.js" }, "scripts": { "build": "tsc", diff --git a/packages/amplify-e2e-gen2-migration/src/cli.ts b/packages/amplify-e2e-gen2-migration/src/cli.ts index 59258fcd4c9..84f1fe0aaf2 100644 --- a/packages/amplify-e2e-gen2-migration/src/cli.ts +++ b/packages/amplify-e2e-gen2-migration/src/cli.ts @@ -79,7 +79,7 @@ async function main(): Promise { } finally { if (argv.teardown) { await app.refreshCredentials(); - await new Teardown(app.deploymentName, app.profile).clean(); + await new Teardown(app.deploymentName, app.getClientConfig()).clean(); } } } diff --git a/packages/amplify-e2e-gen2-migration/src/core/app.ts b/packages/amplify-e2e-gen2-migration/src/core/app.ts index cae9330ce99..3d5e8b95449 100644 --- a/packages/amplify-e2e-gen2-migration/src/core/app.ts +++ b/packages/amplify-e2e-gen2-migration/src/core/app.ts @@ -139,6 +139,7 @@ export class App { */ public async init(): Promise { await this.refreshCredentials(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call await ensureGen1PlaceholderApp(new AmplifyClient(this.getClientConfig())); this.logger.info('amplify init started'); const mainTsx = path.join(this.sourceAppPath, 'src', 'main.tsx'); @@ -361,6 +362,7 @@ export class App { */ public async deployGen2Sandbox(): Promise { await this.refreshCredentials(); + await this.bootstrapCDK(); this.logger.info('Deploying Gen2 app using ampx sandbox...'); const startTime = Date.now(); @@ -501,7 +503,14 @@ export class App { * credential signal — sub-processes resolve it via the shared AWS config. */ public getEnv(extra?: Record): NodeJS.ProcessEnv { - return { ...process.env, AWS_PROFILE: this.profile, ...extra }; + const env: NodeJS.ProcessEnv = { ...process.env, AWS_PROFILE: this.profile, ...extra }; + // Remove credential env vars so subprocesses use only the profile. + // Without this, the AWS CLI and CDK prefer env var credentials over + // the profile, causing operations to run in the wrong account. + delete env.AWS_ACCESS_KEY_ID; + delete env.AWS_SECRET_ACCESS_KEY; + delete env.AWS_SESSION_TOKEN; + return env; } /** @@ -517,6 +526,29 @@ export class App { // Private Helpers // ============================================================ + /** + * Bootstrap CDK in the target account/region. Idempotent — succeeds + * silently if the CDKToolkit stack already exists. + */ + private async bootstrapCDK(): Promise { + 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 result = await execa('npx', ['cdk', 'bootstrap', `aws://${accountId}/${region}`], { + cwd: this.targetAppPath, + reject: false, + stdio: 'inherit', + env: this.getEnv(), + }); + if (result.exitCode !== 0) { + this.logger.info('CDK bootstrap may already exist or failed, continuing...'); + } + } private removeGitignoreLine(line: string): void { const gitignorePath = path.join(this.targetAppPath, '.gitignore'); if (!fs.existsSync(gitignorePath)) return; diff --git a/packages/amplify-e2e-gen2-migration/src/core/credentials.ts b/packages/amplify-e2e-gen2-migration/src/core/credentials.ts index 8f72286e32d..0def2bc5318 100644 --- a/packages/amplify-e2e-gen2-migration/src/core/credentials.ts +++ b/packages/amplify-e2e-gen2-migration/src/core/credentials.ts @@ -3,16 +3,16 @@ import path from 'path'; import os from 'os'; import crypto from 'crypto'; import { STSClient, AssumeRoleCommand } from '@aws-sdk/client-sts'; +import { fromContainerMetadata } from '@aws-sdk/credential-providers'; import { Logger } from './logger'; import { mergeManagedSection } from './ini-merge'; -/** - * Duration for assumed-role sessions. One hour strikes a balance between - * STS call cost and headroom for any single step (all steps are well under - * an hour individually). - */ +/** Duration for assumed-role sessions (1 hour — STS maximum for chained roles). */ const SESSION_DURATION_SECONDS = 3600; +/** Role name used by AWS Organizations for cross-account access. */ +const CHILD_ACCOUNT_ROLE_NAME = 'OrganizationAccountAccessRole'; + /** * Owns the AWS credential lifecycle for a single migration run. * @@ -25,42 +25,48 @@ const SESSION_DURATION_SECONDS = 3600; * The caller-supplied profile is already present in `~/.aws/credentials`. * `refresh()` is a no-op. * - * ## Role mode (`callerProfile` is undefined, `TEST_ACCOUNT_ROLE` is set) + * ## CI mode (`callerProfile` is undefined) + * + * Requires `TEST_ACCOUNT_ROLE` (parent-account role ARN) and + * `CHILD_ACCOUNT_ID` (target child-account ID) in the environment. + * + * `refresh()` performs a two-hop assume-role chain: * - * `refresh()` assumes the role from `TEST_ACCOUNT_ROLE` via STS and merges - * the returned credentials into `~/.aws/credentials` (and region into - * `~/.aws/config`) under a generated profile name. Any pre-existing - * profiles in those files are preserved — the managed section is added if - * absent or replaced in place if already present. + * CodeBuild container creds (long-lived) + * → assume `TEST_ACCOUNT_ROLE` (parent account, 1hr session) + * → assume `OrganizationAccountAccessRole` in `CHILD_ACCOUNT_ID` (1hr session) * - * `refresh()` is idempotent across repeat calls with the same generated - * profile name: calling it many times produces the same on-disk state as - * calling it once. It must be called before each long-running step so - * session tokens don't expire mid-operation. + * The final child-account credentials are written into + * `~/.aws/credentials` under a generated profile name. Because each + * `refresh()` call re-assumes both roles from the CodeBuild base + * credentials, sessions never expire mid-migration — callers just need + * to call `refresh()` before each long-running step. */ export class CredentialManager { private readonly callerProfile: string | undefined; - private readonly roleArn: string | undefined; + private readonly parentRoleArn: string | undefined; + private readonly childAccountId: string | undefined; private readonly region: string; private readonly logger: Logger; private readonly generatedProfile: string; constructor(callerProfile: string | undefined, region: string, generatedProfile: string, logger: Logger) { this.callerProfile = callerProfile; - this.roleArn = callerProfile ? undefined : process.env.TEST_ACCOUNT_ROLE; + this.parentRoleArn = callerProfile ? undefined : process.env.TEST_ACCOUNT_ROLE; + this.childAccountId = callerProfile ? undefined : process.env.CHILD_ACCOUNT_ID; this.region = region; this.generatedProfile = generatedProfile; this.logger = logger; } - /** Whether this manager operates in role mode (CI). */ - private get isRoleMode(): boolean { - return this.roleArn !== undefined; + /** Whether this manager operates in CI mode. */ + private get isCIMode(): boolean { + return this.callerProfile === undefined; } /** - * Name of the AWS profile that `amplify init` should use. In profile mode, - * this is the caller-supplied profile. In role mode, it's the generated + * Name of the AWS profile that subprocesses should use. In profile mode, + * this is the caller-supplied profile. In CI mode, it's the generated * profile merged into `~/.aws/credentials` by `refresh()`. */ public get profile(): string { @@ -68,33 +74,73 @@ export class CredentialManager { } /** - * Refresh credentials if in role mode by assuming the role and merging - * the managed profile into the shared credentials and config files. - * No-op in profile mode — the caller's long-lived profile handles auth. + * Refresh credentials via the two-hop assume-role chain and write the + * result into the named profile. No-op in profile mode. * - * Idempotent: repeated calls with the same generated profile name produce - * the same on-disk state as a single call. + * Each call starts from the CodeBuild container credentials (resolved + * via the default provider chain with env vars cleared), so the + * resulting sessions are always fresh regardless of how long the + * migration has been running. */ public async refresh(): Promise { - if (!this.isRoleMode) { + if (!this.isCIMode) { return; } - this.logger.info('Refreshing credentials...'); - const sts = new STSClient({}); - const assumed = await sts.send( + if (!this.parentRoleArn) { + throw new Error('TEST_ACCOUNT_ROLE must be set in CI mode'); + } + if (!this.childAccountId) { + throw new Error('CHILD_ACCOUNT_ID must be set in CI mode'); + } + + this.logger.info('Refreshing credentials (two-hop assume-role)...'); + + // Hop 1: CodeBuild container creds → parent account + // Explicitly use container metadata credentials so that any + // AWS_ACCESS_KEY_ID/SECRET/TOKEN env vars (e.g. child account + // creds set by the E2E shell wrapper) are bypassed. + const parentSts = new STSClient({ + region: this.region, + credentials: fromContainerMetadata(), + }); + const parentResult = await parentSts.send( new AssumeRoleCommand({ - RoleArn: this.roleArn, - RoleSessionName: `gen2-migration-e2e-${Date.now()}`, + RoleArn: this.parentRoleArn, + RoleSessionName: `gen2-mig-parent-${Date.now()}`, DurationSeconds: SESSION_DURATION_SECONDS, }), ); - const creds = assumed.Credentials; - if (!creds?.AccessKeyId || !creds?.SecretAccessKey || !creds?.SessionToken) { - throw new Error('STS AssumeRole returned incomplete credentials'); + const parentCreds = parentResult.Credentials; + + if (!parentCreds?.AccessKeyId || !parentCreds?.SecretAccessKey || !parentCreds?.SessionToken) { + throw new Error('Failed to assume TEST_ACCOUNT_ROLE — STS returned incomplete credentials'); } + this.logger.info('Hop 1 complete: assumed parent account role'); - this.writeCredentialsFile(creds.AccessKeyId, creds.SecretAccessKey, creds.SessionToken); + // Hop 2: parent account creds → child account + const childSts = new STSClient({ + credentials: { + accessKeyId: parentCreds.AccessKeyId, + secretAccessKey: parentCreds.SecretAccessKey, + sessionToken: parentCreds.SessionToken, + }, + region: this.region, + }); + const childRoleArn = `arn:aws:iam::${this.childAccountId}:role/${CHILD_ACCOUNT_ROLE_NAME}`; + const childResult = await childSts.send( + new AssumeRoleCommand({ + RoleArn: childRoleArn, + RoleSessionName: `gen2-mig-child-${Date.now()}`, + DurationSeconds: SESSION_DURATION_SECONDS, + }), + ); + const childCreds = childResult.Credentials; + if (!childCreds?.AccessKeyId || !childCreds?.SecretAccessKey || !childCreds?.SessionToken) { + throw new Error(`Failed to assume child account role in ${this.childAccountId} — STS returned incomplete credentials`); + } + this.logger.info(`Hop 2 complete: assumed child account role in ${this.childAccountId}`); + this.writeCredentialsFile(childCreds.AccessKeyId, childCreds.SecretAccessKey, childCreds.SessionToken); this.logger.info('Credentials refreshed'); } @@ -187,21 +233,24 @@ function atomicWriteFile(filePath: string, content: string, mode: number): void * Resolve the AWS profile from CLI flags and environment. * * Rules: - * 1. `--profile` + `TEST_ACCOUNT_ROLE` → error (conflicting credential sources) - * 2. `--profile` without `TEST_ACCOUNT_ROLE` → profile mode (local dev) - * 3. No `--profile` + `TEST_ACCOUNT_ROLE` → role mode (CI) + * 1. `--profile` + CI env vars → error (conflicting credential sources) + * 2. `--profile` alone → profile mode (local dev) + * 3. `TEST_ACCOUNT_ROLE` + `CHILD_ACCOUNT_ID` → CI mode * 4. Neither → error */ export function resolveProfile(profile: string | undefined): string | undefined { - const hasTestAccountRole = !!process.env.TEST_ACCOUNT_ROLE; - if (profile && hasTestAccountRole) { - throw new Error('--profile cannot be used when TEST_ACCOUNT_ROLE is set'); + const hasCICredentials = !!process.env.TEST_ACCOUNT_ROLE && !!process.env.CHILD_ACCOUNT_ID; + if (profile && hasCICredentials) { + throw new Error('--profile cannot be used when TEST_ACCOUNT_ROLE and CHILD_ACCOUNT_ID are set'); } if (profile) { return profile; } - if (hasTestAccountRole) { + if (hasCICredentials) { return undefined; } - throw new Error('Either --profile or the TEST_ACCOUNT_ROLE env var must be set'); + if (process.env.TEST_ACCOUNT_ROLE && !process.env.CHILD_ACCOUNT_ID) { + throw new Error('CHILD_ACCOUNT_ID must be set when TEST_ACCOUNT_ROLE is set'); + } + throw new Error('Either --profile or TEST_ACCOUNT_ROLE + CHILD_ACCOUNT_ID env vars must be set'); } diff --git a/packages/amplify-e2e-gen2-migration/src/core/teardown.ts b/packages/amplify-e2e-gen2-migration/src/core/teardown.ts index d6862cc6879..d4cb84232e9 100644 --- a/packages/amplify-e2e-gen2-migration/src/core/teardown.ts +++ b/packages/amplify-e2e-gen2-migration/src/core/teardown.ts @@ -36,10 +36,15 @@ export class Teardown { private readonly clientConfig: { credentials: ReturnType }; private readonly errors: string[] = []; - constructor(deploymentName: string, profile: string) { + /** + * @param deploymentName The deployment name prefix used to discover stacks. + * @param clientConfig SDK client config with a credentials provider. + * Typically obtained from `App.getClientConfig()`. + */ + constructor(deploymentName: string, clientConfig: { credentials: ReturnType }) { this.deploymentName = deploymentName; this.logger = new Logger(`teardown-${deploymentName}`); - this.clientConfig = { credentials: fromIni({ profile }) }; + this.clientConfig = clientConfig; } /** diff --git a/packages/amplify-e2e-gen2-migration/src/index.ts b/packages/amplify-e2e-gen2-migration/src/index.ts new file mode 100644 index 00000000000..4aac1e3ddbb --- /dev/null +++ b/packages/amplify-e2e-gen2-migration/src/index.ts @@ -0,0 +1,2 @@ +export { App } from './core/app'; +export { Teardown } from './core/teardown'; diff --git a/packages/amplify-e2e-gen2-migration/tsconfig.json b/packages/amplify-e2e-gen2-migration/tsconfig.json index 5805982e852..e0dc71b9814 100644 --- a/packages/amplify-e2e-gen2-migration/tsconfig.json +++ b/packages/amplify-e2e-gen2-migration/tsconfig.json @@ -5,6 +5,7 @@ "lib": ["ES2020"], "outDir": "./lib", "rootDir": "./src", + "composite": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, diff --git a/packages/amplify-e2e-tests/package.json b/packages/amplify-e2e-tests/package.json index a4fd309c8b3..aaed43a01da 100644 --- a/packages/amplify-e2e-tests/package.json +++ b/packages/amplify-e2e-tests/package.json @@ -29,6 +29,7 @@ "@aws-amplify/amplify-category-auth": "3.7.27", "@aws-amplify/amplify-cli-core": "4.5.0", "@aws-amplify/amplify-e2e-core": "5.7.12", + "@aws-amplify/amplify-e2e-gen2-migration": "workspace:^", "@aws-amplify/amplify-opensearch-simulator": "1.7.25", "@aws-amplify/graphql-transformer-core": "^2.11.3", "@aws-sdk/client-amplify": "^3.919.0", 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/migrate-backend-only.test.ts new file mode 100644 index 00000000000..29c3ef11dbb --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-backend-only.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - backend-only', () => { + it( + 'migrates the backend-only app from Gen1 to Gen2', + async () => { + await runMigrationE2E('backend-only'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); diff --git a/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-discussions.test.ts b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-discussions.test.ts new file mode 100644 index 00000000000..3525f7bdd77 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-discussions.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - discussions', () => { + it( + 'migrates the discussions app from Gen1 to Gen2', + async () => { + await runMigrationE2E('discussions'); + }, + 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/migrate-fitness-tracker.test.ts new file mode 100644 index 00000000000..f0ba1eb4412 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-fitness-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 - fitness-tracker', () => { + it( + 'migrates the fitness-tracker app from Gen1 to Gen2', + async () => { + await runMigrationE2E('fitness-tracker'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); 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/migrate-media-vault.test.ts new file mode 100644 index 00000000000..c22376732a9 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-media-vault.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - media-vault', () => { + it( + 'migrates the media-vault app from Gen1 to Gen2', + async () => { + await runMigrationE2E('media-vault'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); 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/migrate-mood-board.test.ts new file mode 100644 index 00000000000..298dbbed4bd --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-mood-board.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - mood-board', () => { + it( + 'migrates the mood-board app from Gen1 to Gen2', + async () => { + await runMigrationE2E('mood-board'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); 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/migrate-product-catalog.test.ts new file mode 100644 index 00000000000..4702df381d8 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-product-catalog.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - product-catalog', () => { + it( + 'migrates the product-catalog app from Gen1 to Gen2', + async () => { + await runMigrationE2E('product-catalog'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); 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/migrate-project-boards.test.ts new file mode 100644 index 00000000000..2abdeda41e4 --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-project-boards.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - project-boards', () => { + it( + 'migrates the project-boards app from Gen1 to Gen2', + async () => { + await runMigrationE2E('project-boards'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); 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/migrate-store-locator.test.ts new file mode 100644 index 00000000000..f640df2313a --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/migrate-store-locator.test.ts @@ -0,0 +1,12 @@ +/* eslint-disable spellcheck/spell-checker */ +import { runMigrationE2E, MIGRATION_TEST_TIMEOUT_MS } from './run-migration-e2e'; + +describe('gen2 migration - store-locator', () => { + it( + 'migrates the store-locator app from Gen1 to Gen2', + async () => { + await runMigrationE2E('store-locator'); + }, + MIGRATION_TEST_TIMEOUT_MS, + ); +}); 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 new file mode 100644 index 00000000000..a53cc99814f --- /dev/null +++ b/packages/amplify-e2e-tests/src/__tests__/gen2-migration/run-migration-e2e.ts @@ -0,0 +1,67 @@ +/* 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'; + +/** + * Jest timeout for migration tests (2 hours). Migration runs involve + * full Gen1 push + Gen2 sandbox deploy and can take 30–90 minutes. + */ +export const MIGRATION_TEST_TIMEOUT_MS = 2 * 60 * 60 * 1000; + +/** + * Resolve the child account ID from the current STS caller identity. + * + * The shell-level `setAwsAccountCredentials` has already assumed into a + * child account and set AWS_ACCESS_KEY_ID/SECRET/TOKEN in the env. + * `configure_tests.ts` wrote those same credentials to the + * `amplify-integ-test-user` profile that `amplify init`/`push` use. + * + * We must use the SAME account for the Gen2 sandbox, so we read the + * account ID from the current env var credentials rather than picking + * a random one. + */ +async function resolveChildAccountId(): Promise { + const sts = new STSClient({}); + const response = await sts.send(new GetCallerIdentityCommand({})); + const accountId = response.Account; + console.log(`Selected child account: ${accountId}`); + return accountId; +} + +/** + * Run the gen2-migration E2E for a single app by calling App.migrate() + * directly in-process. + * + * Sets up the environment so that the App's CredentialManager operates + * in CI mode (two-hop assume-role from container credentials). + */ +export async function runMigrationE2E(appName: string): Promise { + // 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(); + process.env.CHILD_ACCOUNT_ID = childAccountId; + + // Configure git identity — the migration workflow makes commits. + execSync('git config --global user.email "amplify-cli-e2e@test.com"', { encoding: 'utf-8' }); + execSync('git config --global user.name "Amplify CLI E2E Test Name"', { encoding: 'utf-8' }); + + // Construct App with profile=undefined to trigger CI mode in + // 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); + } + } +} diff --git a/packages/amplify-e2e-tests/src/__tests__/hosting.test.ts b/packages/amplify-e2e-tests/src/__tests__/hosting.test.ts index f04827b0aa3..81774432937 100644 --- a/packages/amplify-e2e-tests/src/__tests__/hosting.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/hosting.test.ts @@ -77,7 +77,7 @@ describe('amplify add hosting', () => { error = err; } expect(error).toBeDefined(); - expect(error.message).toEqual('Process exited with non zero exit code 1'); + expect(error.message).toContain('Process exited with non zero exit code 1'); resetBuildCommand(projRoot, currentBuildCommand); }); }); diff --git a/packages/amplify-e2e-tests/src/__tests__/hostingPROD.test.ts b/packages/amplify-e2e-tests/src/__tests__/hostingPROD.test.ts index a55a73f2fed..c4f0ce837a6 100644 --- a/packages/amplify-e2e-tests/src/__tests__/hostingPROD.test.ts +++ b/packages/amplify-e2e-tests/src/__tests__/hostingPROD.test.ts @@ -72,7 +72,7 @@ describe('amplify add hosting', () => { resetBuildCommand(projRoot, currentBuildCommand); } expect(error).toBeDefined(); - expect(error.message).toEqual('Process exited with non zero exit code 1'); + expect(error.message).toContain('Process exited with non zero exit code 1'); }); }); diff --git a/packages/amplify-e2e-tests/src/cleanup-codebuild-resources.ts b/packages/amplify-e2e-tests/src/cleanup-codebuild-resources.ts index 9b3918632b7..47de4c12ca5 100644 --- a/packages/amplify-e2e-tests/src/cleanup-codebuild-resources.ts +++ b/packages/amplify-e2e-tests/src/cleanup-codebuild-resources.ts @@ -50,11 +50,13 @@ import { waitUntilBucketNotExists, } from '@aws-sdk/client-s3'; import { STSClient, GetCallerIdentityCommand, AssumeRoleCommand } from '@aws-sdk/client-sts'; -import { GEN1_PLACEHOLDER_APP_NAME } from '@aws-amplify/amplify-e2e-core'; import fs from 'fs-extra'; import _ from 'lodash'; import path from 'path'; +/** Inlined from @aws-amplify/amplify-e2e-core to avoid requiring a workspace build. */ +const GEN1_PLACEHOLDER_APP_NAME = 'gen1-placeholder-do-not-delete'; + const AWS_REGIONS_TO_RUN_TESTS = [ 'us-east-1', 'us-east-2', diff --git a/packages/amplify-e2e-tests/tsconfig.tests.json b/packages/amplify-e2e-tests/tsconfig.tests.json index 7d25f362abd..cca83952e46 100644 --- a/packages/amplify-e2e-tests/tsconfig.tests.json +++ b/packages/amplify-e2e-tests/tsconfig.tests.json @@ -6,6 +6,6 @@ "strict": false, "lib": ["ESNext", "dom"] }, - "references": [{ "path": "../amplify-e2e-core" }], + "references": [{ "path": "../amplify-e2e-core" }, { "path": "../amplify-e2e-gen2-migration" }], "exclude": ["node_modules", "lib", "__tests__", "custom-resources", "overrides"] } diff --git a/scripts/split-e2e-tests-codebuild.ts b/scripts/split-e2e-tests-codebuild.ts index 5c202e696c9..47eaee5d7b9 100644 --- a/scripts/split-e2e-tests-codebuild.ts +++ b/scripts/split-e2e-tests-codebuild.ts @@ -60,6 +60,14 @@ 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', ]; const TEST_EXCLUSIONS: { l: string[]; w: string[] } = { l: [], @@ -128,6 +136,15 @@ 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', ], }; export function loadConfigBase() { @@ -284,7 +301,10 @@ const splitTestsV3 = ( identifier, }; formattedJob.env.variables = {}; - if (isMigration || job.tests.length === 1) { + const isGen2Migration = job.tests.some((t) => t.includes('gen2-migration')); + if (isGen2Migration) { + formattedJob.env.variables['compute-type'] = 'BUILD_GENERAL1_LARGE'; + } else if (isMigration || job.tests.length === 1) { formattedJob.env.variables['compute-type'] = 'BUILD_GENERAL1_SMALL'; } formattedJob.env.variables.TEST_SUITE = job.tests.join('|'); diff --git a/shared-scripts.sh b/shared-scripts.sh index 42f240b2205..627fd95ec95 100644 --- a/shared-scripts.sh +++ b/shared-scripts.sh @@ -792,47 +792,3 @@ function _deploymentVerificationRCOrTagged { version=$(cat .amplify-pkg-version) yarn ts-node scripts/verify-deployment.ts --version $version --exclude-github } - -function _runGen2MigrationE2E { - echo "Running Gen2 Migration E2E test for app: $MIGRATION_APP" - - # Load cached artifacts - _loadE2ECache - _install_packaged_cli_linux - - # Verify CLI installation - which amplify - amplify version - - # Start local registry for npm packages - source .circleci/local_publish_helpers_codebuild.sh && startLocalRegistry "$CODEBUILD_SRC_DIR/.circleci/verdaccio.yaml" - setNpmRegistryUrlToLocal - changeNpmGlobalPath - - # Load test account credentials - _loadTestAccountCredentials - - # Bootstrap CDK if not already done (required for ampx sandbox) - # Uses env-var credentials from _loadTestAccountCredentials. Idempotent. - echo "Bootstrapping CDK for region ${CLI_REGION:-us-east-1}..." - npx cdk bootstrap aws://$(aws sts get-caller-identity --query Account --output text)/${CLI_REGION:-us-east-1} || echo "Bootstrap may already exist or failed, continuing..." - - # Unset env credentials so the migration CLI is the sole auth source. - # The CLI reads TEST_ACCOUNT_ROLE, assumes the role via STS, and writes a named - # profile to ~/.aws/credentials. It also refreshes the profile before each - # long-running step so sessions don't expire mid-migration. - unset AWS_ACCESS_KEY_ID - unset AWS_SECRET_ACCESS_KEY - unset AWS_SESSION_TOKEN - - # Configure git identity for commits during migration - git config --global user.email "amplify-cli-e2e@test.com" - git config --global user.name "Amplify CLI E2E Test Name" - - # Run the e2e migration test - echo "Starting migration E2E test for $MIGRATION_APP" - cd $CODEBUILD_SRC_DIR/packages/amplify-e2e-gen2-migration - npx tsx src/cli.ts --app $MIGRATION_APP ${TEARDOWN:+--teardown} - - echo "Migration E2E test completed for $MIGRATION_APP" -} diff --git a/yarn.lock b/yarn.lock index fea58af734c..6329942a3ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -585,7 +585,7 @@ __metadata: languageName: unknown linkType: soft -"@aws-amplify/amplify-e2e-gen2-migration@workspace:packages/amplify-e2e-gen2-migration": +"@aws-amplify/amplify-e2e-gen2-migration@workspace:^, @aws-amplify/amplify-e2e-gen2-migration@workspace:packages/amplify-e2e-gen2-migration": version: 0.0.0-use.local resolution: "@aws-amplify/amplify-e2e-gen2-migration@workspace:packages/amplify-e2e-gen2-migration" dependencies: @@ -622,7 +622,7 @@ __metadata: uuid: ^9.0.1 yargs: ^17.7.2 bin: - amplify-migrate: dist/cli.js + amplify-migrate: lib/cli.js languageName: unknown linkType: soft @@ -15961,6 +15961,7 @@ __metadata: "@aws-amplify/amplify-category-auth": 3.7.27 "@aws-amplify/amplify-cli-core": 4.5.0 "@aws-amplify/amplify-e2e-core": 5.7.12 + "@aws-amplify/amplify-e2e-gen2-migration": "workspace:^" "@aws-amplify/amplify-opensearch-simulator": 1.7.25 "@aws-amplify/graphql-transformer-core": ^2.11.3 "@aws-sdk/client-amplify": ^3.919.0