From c7574f03bde6d069cfdaba9784581da42fd1ee34 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 14:33:56 -0400 Subject: [PATCH 1/8] feat: add apiAccess and secrets support for Lambda functions --- .../product-catalog/configure.sh | 5 +- .../product-catalog/lowstockproducts.js | 5 +- .../product-catalog/migration-config.json | 8 +- .../src/cli.ts | 118 ++++++++ .../src/core/category-initializer.ts | 254 ++++++++++++++++-- .../src/core/gen2-migration-executor.ts | 4 +- .../src/types/index.ts | 4 + 7 files changed, 371 insertions(+), 27 deletions(-) diff --git a/amplify-migration-apps/product-catalog/configure.sh b/amplify-migration-apps/product-catalog/configure.sh index b4f6510f709..9890f8e9d7f 100755 --- a/amplify-migration-apps/product-catalog/configure.sh +++ b/amplify-migration-apps/product-catalog/configure.sh @@ -2,11 +2,12 @@ set -euxo pipefail +api_name=$(ls amplify/backend/api) s3_trigger_function_name=$(ls amplify/backend/function | grep S3Trigger) -cp -f schema.graphql ./amplify/backend/api/productcatalog/schema.graphql +cp -f schema.graphql ./amplify/backend/api/${api_name}/schema.graphql cp -f lowstockproducts.js ./amplify/backend/function/lowstockproducts/src/index.js cp -f lowstockproducts.package.json ./amplify/backend/function/lowstockproducts/src/package.json cp -f onimageuploaded.js ./amplify/backend/function/${s3_trigger_function_name}/src/index.js cp -f onimageuploaded.package.json ./amplify/backend/function/${s3_trigger_function_name}/src/package.json -cp -f custom-roles.json ./amplify/backend/api/productcatalog/custom-roles.json \ No newline at end of file +cp -f custom-roles.json ./amplify/backend/api/${api_name}/custom-roles.json diff --git a/amplify-migration-apps/product-catalog/lowstockproducts.js b/amplify-migration-apps/product-catalog/lowstockproducts.js index 23653856e33..dfa5928f3eb 100644 --- a/amplify-migration-apps/product-catalog/lowstockproducts.js +++ b/amplify-migration-apps/product-catalog/lowstockproducts.js @@ -6,7 +6,10 @@ const { SSMClient, GetParametersCommand } = require('@aws-sdk/client-ssm'); const Sha256 = crypto.Sha256; -const GRAPHQL_ENDPOINT = process.env.API_PRODUCTCATALOG_GRAPHQLAPIENDPOINTOUTPUT; +const GRAPHQL_ENDPOINT = Object.keys(process.env) + .filter((k) => k.startsWith('API_') && k.endsWith('_GRAPHQLAPIENDPOINTOUTPUT')) + .map((k) => process.env[k]) + .find(Boolean); const AWS_REGION = process.env.AWS_REGION || 'us-east-1'; const LOW_STOCK_THRESHOLD = parseInt(process.env.LOW_STOCK_THRESHOLD) || 5; diff --git a/amplify-migration-apps/product-catalog/migration-config.json b/amplify-migration-apps/product-catalog/migration-config.json index c2b9ac3e0b9..dd1d348bf56 100644 --- a/amplify-migration-apps/product-catalog/migration-config.json +++ b/amplify-migration-apps/product-catalog/migration-config.json @@ -35,7 +35,13 @@ { "name": "lowstockproducts", "runtime": "nodejs", - "template": "hello-world" + "template": "hello-world", + "secrets": { + "PRODUCT_CATALOG_SECRET": "product-catalog-secret-value" + }, + "apiAccess": { + "operations": ["Query"] + } } ] }, diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index ddbe8c58dca..66b4b028c8d 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -491,6 +491,39 @@ async function runGen2TestScript(targetAppPath: string, migrationTargetPath: str logger.info(`${testScriptName} completed successfully`); } +/** + * Resolve custom-roles.json for apps that use AppSync admin roles. + * Replaces the ${appId} placeholder with the deployment name and adds + * Lambda execution role prefixes so IAM-signed Lambda calls are authorized. + */ +async function resolveCustomRolesJson(sourceAppPath: string, targetAppPath: string, deploymentName: string): Promise { + const customRolesSource = path.join(sourceAppPath, 'custom-roles.json'); + if (!fs.existsSync(customRolesSource)) { + logger.debug('No custom-roles.json found in app source, skipping'); + return; + } + + const apiDir = path.join(targetAppPath, 'amplify', 'backend', 'api'); + if (!fs.existsSync(apiDir)) return; + const apiEntries = fs.readdirSync(apiDir); + if (apiEntries.length === 0) return; + const apiName = apiEntries[0]; + + const projectConfigPath = path.join(targetAppPath, 'amplify', '.config', 'project-config.json'); + let resolvedName = deploymentName; + if (fs.existsSync(projectConfigPath)) { + const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf-8')) as { projectName?: string }; + if (projectConfig.projectName) resolvedName = projectConfig.projectName; + } + + const customRoles = { + adminRoleNames: [`amplify-${resolvedName}`, `${resolvedName}LambdaRole`, 'amplifyAuthauthenticatedU'], + }; + const targetPath = path.join(apiDir, apiName, 'custom-roles.json'); + fs.writeFileSync(targetPath, JSON.stringify(customRoles, null, 2) + '\n', 'utf-8'); + logger.info(`Resolved custom-roles.json with deployment name: ${resolvedName}`); +} + /** * Spawn the amplify CLI directly to run amplify push --yes. * @@ -596,16 +629,97 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise logger.debug(`No configure.sh found for ${deploymentName}, skipping`, context); } + // Resolve custom-roles.json if the app has one (must happen before push) + await resolveCustomRolesJson(sourceAppPath, targetAppPath, deploymentName); + // Push the initialized app to AWS logger.info(`Pushing ${deploymentName} to AWS...`, context); await amplifyPush(targetAppPath); logger.info(`Successfully pushed ${deploymentName} to AWS`, context); + // Update deployed Lambda env vars for functions with API access. + // addFunction with additionalPermissions sets dependsOn to auth, so the deployed + // Lambda gets AUTH_* env vars but not API_* env vars. We read the GraphQL endpoint + // from amplify-meta.json and update the Lambda configuration directly. + if (config.categories?.function && config.categories?.api) { + const postPushApiName = categoryInitializer.getApiNameFromBackend(targetAppPath); + if (postPushApiName) { + const amplifyMetaPath = path.join(targetAppPath, 'amplify', 'backend', 'amplify-meta.json'); + if (fs.existsSync(amplifyMetaPath)) { + const amplifyMeta = JSON.parse(fs.readFileSync(amplifyMetaPath, 'utf-8')) as Record< + string, + Record> + >; + const apiMeta = amplifyMeta.api?.[postPushApiName]?.output as Record | undefined; + if (apiMeta?.GraphQLAPIEndpointOutput) { + const region = (amplifyMeta.providers?.awscloudformation?.Region as string) || 'us-east-1'; + for (const func of config.categories.function.functions) { + if (func.apiAccess) { + const functionMeta = amplifyMeta.function?.[func.name]?.output as Record | undefined; + const lambdaName = functionMeta?.Name; + if (lambdaName) { + logger.debug(`Updating ${lambdaName} env vars with API endpoint`, context); + const upper = postPushApiName.toUpperCase(); + // Get existing env vars first, then merge (update-function-configuration replaces all vars) + const getLambdaResult = await execa('aws', [ + 'lambda', + 'get-function-configuration', + '--function-name', + lambdaName, + '--region', + region, + '--profile', + profile, + '--query', + 'Environment.Variables', + '--output', + 'json', + ]); + const existingVars = JSON.parse(getLambdaResult.stdout || '{}') as Record; + const mergedVars = { + ...existingVars, + [`API_${upper}_GRAPHQLAPIIDOUTPUT`]: apiMeta.GraphQLAPIIdOutput || '', + [`API_${upper}_GRAPHQLAPIENDPOINTOUTPUT`]: apiMeta.GraphQLAPIEndpointOutput, + [`API_${upper}_GRAPHQLAPIKEYOUTPUT`]: apiMeta.GraphQLAPIKeyOutput || '', + }; + await execa('aws', [ + 'lambda', + 'update-function-configuration', + '--function-name', + lambdaName, + '--environment', + JSON.stringify({ Variables: mergedVars }), + '--region', + region, + '--profile', + profile, + ]); + logger.info(`Updated ${func.name} Lambda with API env vars`, context); + } + } + } + } + } + } + } + // Run gen1 test script to validate the Gen1 deployment logger.info(`Running gen1 test script (post-push) for ${deploymentName}...`, context); await runGen1TestScript(targetAppPath, migrationTargetPath, sourceAppsBasePath); logger.info(`Gen1 test script passed (post-push) for ${deploymentName}`, context); + // Patch backend-config.json and CFN templates for functions with API access. + // Must happen AFTER push (which needs auth dependsOn) but BEFORE git commit + // (so gen2-migration generate sees api dependsOn in the committed state). + const apiName = categoryInitializer.getApiNameFromBackend(targetAppPath); + if (apiName && config.categories?.function) { + for (const func of config.categories.function.functions) { + if (func.apiAccess) { + categoryInitializer.patchRegularFunctionApiAccess(targetAppPath, func.name, apiName, func.apiAccess.operations, context); + } + } + } + // Initialize git repo and commit the Gen1 state logger.info(`Initializing git repository for ${deploymentName}...`, context); await execa('git', ['init'], { cwd: targetAppPath }); @@ -627,6 +741,10 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise await execa('git', ['commit', '-m', 'feat: gen2 migration generate'], { cwd: targetAppPath }); logger.info(`Gen2 generated code committed`, context); + // Reinstall dependencies to pick up Gen2 deps (ampx, @aws-amplify/backend, etc.) + logger.info(`Reinstalling dependencies before Gen2 deployment...`, context); + await execa('npm', ['install'], { cwd: targetAppPath }); + // Deploy Gen2 using ampx sandbox logger.info(`Deploying Gen2 app using ampx sandbox for ${deploymentName}...`, context); const gen2BranchName = `gen2-${envName}`; diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts index 39eecf85fa3..3a787ab5309 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts @@ -35,6 +35,26 @@ import { import * as fs from 'fs'; import * as path from 'path'; +/** Minimal shape of a CloudFormation template used for CFN patching. */ +interface CfnTemplate { + Parameters?: Record>; + Resources?: Record & { + LambdaFunction?: { + Properties?: { + Environment?: { + Variables?: Record; + }; + }; + }; + }; +} + +/** Minimal shape of backend-config.json / amplify-meta.json used for patching. */ +interface AmplifyBackendConfig { + api?: Record; + function?: Record; +} + export interface CategoryInitializerOptions { appPath: string; config: AppConfiguration; @@ -74,11 +94,12 @@ export class CategoryInitializer { // Initialize categories in the correct order: // 1. Auth first (other categories may depend on it) // 2. Analytics before functions (functions may reference analytics resources) - // 3. Regular functions (non-trigger) before API + // 3. Regular functions WITHOUT API access before storage/API // 4. Storage (may have triggers that reference functions) // 5. GraphQL API (creates AppSync tables that trigger functions may reference) - // 6. Trigger functions (need AppSync/DynamoDB tables to exist) - // 7. REST API last (needs functions to exist) + // 6. Regular functions WITH API access (need API to exist for env vars) + // 7. Trigger functions (need AppSync/DynamoDB tables to exist) + // 8. REST API last (needs functions to exist) if (categories.auth) { await this.initializeAuthCategory(appPath, categories.auth, result, context); } @@ -87,9 +108,9 @@ export class CategoryInitializer { await this.initializeAnalyticsCategory(appPath, categories.analytics, result, context); } - // Initialize regular (non-trigger) functions before API + // Initialize regular functions that do NOT need API access (before API) if (categories.function) { - await this.initializeRegularFunctions(appPath, categories.function, result, context); + await this.initializeRegularFunctions(appPath, categories.function, false, result, context); } if (categories.storage) { @@ -100,6 +121,11 @@ export class CategoryInitializer { await this.initializeApiCategory(appPath, categories.api, categories.function, result, context); } + // Initialize regular functions that need API access (after API exists) + if (categories.function && categories.api) { + await this.initializeRegularFunctions(appPath, categories.function, true, result, context); + } + // Initialize trigger functions after API (they need AppSync tables to exist) if (categories.function) { await this.initializeTriggerFunctions(appPath, categories.function, result, context); @@ -450,22 +476,27 @@ export class CategoryInitializer { } /** - * Initialize regular (non-trigger) Lambda functions + * Initialize regular (non-trigger) Lambda functions. + * When withApiAccess is false, creates functions that don't need API access. + * When withApiAccess is true, creates functions that need API access (must be called after API init). */ private async initializeRegularFunctions( appPath: string, functionConfig: FunctionConfiguration, + withApiAccess: boolean, result: InitializeCategoriesResult, context: LogContext, ): Promise { - const regularFunctions = functionConfig.functions.filter((f) => !f.trigger); + const regularFunctions = functionConfig.functions + .filter((f) => !f.trigger) + .filter((f) => (withApiAccess ? !!f.apiAccess : !f.apiAccess)); if (regularFunctions.length === 0) { - this.logger.debug('No regular functions to initialize', context); return; } - this.logger.info(`Initializing ${regularFunctions.length} regular function(s)...`, context); + const label = withApiAccess ? 'regular function(s) with API access' : 'regular function(s)'; + this.logger.info(`Initializing ${regularFunctions.length} ${label}...`, context); try { for (const func of regularFunctions) { @@ -474,23 +505,52 @@ export class CategoryInitializer { const runtime = this.mapRuntime(func.runtime); const template = this.mapTemplate(func.template); - await addFunction( - appPath, - { - name: func.name, - functionTemplate: template, - }, - runtime, - ); + const settings: Record = { + name: func.name, + functionTemplate: template, + }; + + // Wire secrets (addFunction supports one at a time) + if (func.secrets) { + const entries = Object.entries(func.secrets); + if (entries.length > 0) { + const [name, value] = entries[0]; + settings.secretsConfig = { operation: 'add', name, value }; + this.logger.debug(`Adding secret ${name} to ${func.name}`, context); + } + } + + // Wire API access permissions (only when withApiAccess=true, API must exist) + if (withApiAccess && func.apiAccess) { + const apiName = this.getApiNameFromBackend(appPath); + if (apiName) { + settings.additionalPermissions = { + permissions: ['api'], + choices: ['api', 'auth', 'function', 'storage'], + resources: [apiName], + operations: func.apiAccess.operations, + }; + this.logger.debug(`Adding API access (${func.apiAccess.operations.join(', ')}) to ${func.name}`, context); + } + } + + await addFunction(appPath, settings as { name: string; functionTemplate: string }, runtime); + + // Patch CFN template with appsync:GraphQL permission for functions that call back to AppSync + if (withApiAccess && func.apiAccess) { + this.patchFunctionAppsyncPermission(appPath, func.name, context); + } this.logger.debug(`Function ${func.name} added successfully`, context); } - result.initializedCategories.push('function'); - this.logger.info('Regular functions initialized successfully', context); + if (!result.initializedCategories.includes('function')) { + result.initializedCategories.push('function'); + } + this.logger.info(`${label} initialized successfully`, context); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - this.logger.error(`Failed to initialize regular functions: ${errorMessage}`, error as Error, context); + this.logger.error(`Failed to initialize ${label}: ${errorMessage}`, error as Error, context); result.errors.push({ category: 'function', error: errorMessage }); } } @@ -608,7 +668,7 @@ export class CategoryInitializer { /** * Get the API name from the amplify backend configuration */ - private getApiNameFromBackend(appPath: string): string | null { + public getApiNameFromBackend(appPath: string): string | null { try { const backendConfigPath = path.join(appPath, 'amplify', 'backend', 'backend-config.json'); if (fs.existsSync(backendConfigPath)) { @@ -622,6 +682,158 @@ export class CategoryInitializer { } } + /** + * Discover the actual S3 trigger function name from the backend directory. + * The trigger name in migration-config.json is a prefix; the actual function has a random suffix. + */ + public discoverS3TriggerFunctionName(appPath: string, triggerNamePrefix: string): string | null { + const functionDir = path.join(appPath, 'amplify', 'backend', 'function'); + if (!fs.existsSync(functionDir)) { + return null; + } + const entries = fs.readdirSync(functionDir); + return entries.find((name) => name.startsWith(triggerNamePrefix)) ?? null; + } + + /** + * Patch a function's CFN template to add appsync:GraphQL permission. + * Required for functions that make IAM-signed callbacks to AppSync. + * Note: API env vars are wired by patchRegularFunctionApiAccess called from cli.ts. + */ + private patchFunctionAppsyncPermission(appPath: string, functionName: string, context: LogContext): void { + const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); + if (!fs.existsSync(cfnPath)) { + this.logger.warn(`CFN template not found for ${functionName}, skipping appsync:GraphQL patch`, context); + return; + } + + const cfn = JSON.parse(fs.readFileSync(cfnPath, 'utf-8')) as CfnTemplate; + cfn.Resources = cfn.Resources ?? {}; + cfn.Resources.AppSyncGraphQLPolicy = { + Type: 'AWS::IAM::Policy', + Properties: { + PolicyName: 'appsync-graphql-policy', + Roles: [{ Ref: 'LambdaExecutionRole' }], + PolicyDocument: { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['appsync:GraphQL'], + Resource: { 'Fn::Sub': 'arn:aws:appsync:${AWS::Region}:${AWS::AccountId}:apis/*' }, + }, + ], + }, + }, + DependsOn: ['LambdaExecutionRole'], + }; + + fs.writeFileSync(cfnPath, JSON.stringify(cfn, null, 2) + '\n', 'utf-8'); + this.logger.debug(`Patched ${functionName} CFN template with appsync:GraphQL permission`, context); + } + + /** + * Patch backend-config.json and CFN template for a function with API access. + * Replaces auth dependsOn with API dependsOn and adds API env vars. + */ + public patchRegularFunctionApiAccess( + appPath: string, + functionName: string, + apiName: string, + operations: string[], + context: LogContext, + ): void { + const apiDependsOn = [ + { + category: 'api', + resourceName: apiName, + attributes: ['GraphQLAPIIdOutput', 'GraphQLAPIEndpointOutput', 'GraphQLAPIKeyOutput'], + }, + ]; + + // Patch backend-config.json + const configPaths = [ + path.join(appPath, 'amplify', 'backend', 'backend-config.json'), + path.join(appPath, 'amplify', '#current-cloud-backend', 'backend-config.json'), + ]; + for (const configPath of configPaths) { + if (!fs.existsSync(configPath)) continue; + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as AmplifyBackendConfig; + if (config.function?.[functionName]) { + config.function[functionName].dependsOn = apiDependsOn; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } + } + this.logger.debug(`Patched backend-config.json: set ${functionName} dependsOn to api/${apiName}`, context); + + // Patch amplify-meta.json + const metaPaths = [ + path.join(appPath, 'amplify', 'backend', 'amplify-meta.json'), + path.join(appPath, 'amplify', '#current-cloud-backend', 'amplify-meta.json'), + ]; + for (const metaPath of metaPaths) { + if (!fs.existsSync(metaPath)) continue; + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as AmplifyBackendConfig; + if (meta.function?.[functionName]) { + meta.function[functionName].dependsOn = apiDependsOn; + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); + } + } + + // Patch function-parameters.json + const funcParamsPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, 'function-parameters.json'); + let funcParams: Record = {}; + if (fs.existsSync(funcParamsPath)) { + funcParams = JSON.parse(fs.readFileSync(funcParamsPath, 'utf-8')) as Record; + } + funcParams.permissions = { api: { [apiName]: operations } }; + delete funcParams.dependsOn; + fs.writeFileSync(funcParamsPath, JSON.stringify(funcParams, null, 2) + '\n', 'utf-8'); + this.logger.debug(`Patched function-parameters.json for ${functionName}`, context); + + // Patch CFN template: add API parameters and env vars + const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); + if (!fs.existsSync(cfnPath)) return; + + const cfn = JSON.parse(fs.readFileSync(cfnPath, 'utf-8')) as CfnTemplate; + + // Remove auth parameters + if (cfn.Parameters) { + for (const paramName of Object.keys(cfn.Parameters)) { + if (paramName.startsWith('auth')) { + delete cfn.Parameters[paramName]; + } + } + } + + // Add API CFN parameters + cfn.Parameters = cfn.Parameters ?? {}; + const apiParams: Record = { + [`api${apiName}GraphQLAPIIdOutput`]: `api${apiName}GraphQLAPIIdOutput`, + [`api${apiName}GraphQLAPIEndpointOutput`]: `api${apiName}GraphQLAPIEndpointOutput`, + [`api${apiName}GraphQLAPIKeyOutput`]: `api${apiName}GraphQLAPIKeyOutput`, + }; + for (const [paramName, defaultValue] of Object.entries(apiParams)) { + cfn.Parameters[paramName] = { Type: 'String', Default: defaultValue }; + } + + // Remove auth env vars and add API env vars + const envVars = cfn.Resources?.LambdaFunction?.Properties?.Environment?.Variables; + if (envVars) { + for (const envKey of Object.keys(envVars as Record)) { + if (envKey.startsWith('AUTH_')) { + delete (envVars as Record)[envKey]; + } + } + envVars[`API_${apiName.toUpperCase()}_GRAPHQLAPIIDOUTPUT`] = { Ref: `api${apiName}GraphQLAPIIdOutput` }; + envVars[`API_${apiName.toUpperCase()}_GRAPHQLAPIENDPOINTOUTPUT`] = { Ref: `api${apiName}GraphQLAPIEndpointOutput` }; + envVars[`API_${apiName.toUpperCase()}_GRAPHQLAPIKEYOUTPUT`] = { Ref: `api${apiName}GraphQLAPIKeyOutput` }; + } + + fs.writeFileSync(cfnPath, JSON.stringify(cfn, null, 2) + '\n', 'utf-8'); + this.logger.debug(`Patched ${functionName} CFN template with API parameters and env vars`, context); + } + /** * Initialize the analytics category * Supports: Kinesis Data Streams diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts b/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts index 55fdecd4961..b32c2e20286 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts @@ -107,7 +107,7 @@ export class Gen2MigrationExecutor { * and adds GEN2_MIGRATION_ENVIRONMENT_NAME env var to the Amplify app. */ public async lock(appPath: string): Promise { - await this.executeStep('lock', appPath); + await this.executeStep('lock', appPath, ['--skip-validations']); } /** @@ -117,7 +117,7 @@ export class Gen2MigrationExecutor { * folder with Gen2 TypeScript definitions, and installs dependencies. */ public async generate(appPath: string): Promise { - await this.executeStep('generate', appPath); + await this.executeStep('generate', appPath, ['--skip-validations']); } /** diff --git a/packages/amplify-gen2-migration-e2e-system/src/types/index.ts b/packages/amplify-gen2-migration-e2e-system/src/types/index.ts index ba08bc1732f..20cec1358b9 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/types/index.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/types/index.ts @@ -152,6 +152,10 @@ export interface LambdaFunction { environment?: Record; permissions?: string[]; trigger?: FunctionTrigger; + secrets?: Record; + apiAccess?: { + operations: string[]; + }; } export interface FunctionTrigger { From 2bfb32944dd9600199f55f353035dc565fb233c2 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 14:48:02 -0400 Subject: [PATCH 2/8] chore: skip validations flag doesn't run for every app --- .../amplify-gen2-migration-e2e-system/src/cli.ts | 4 +++- .../src/core/gen2-migration-executor.ts | 16 +++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index 66b4b028c8d..04f4e178320 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -711,11 +711,13 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise // Patch backend-config.json and CFN templates for functions with API access. // Must happen AFTER push (which needs auth dependsOn) but BEFORE git commit // (so gen2-migration generate sees api dependsOn in the committed state). + let didPostPushPatching = false; const apiName = categoryInitializer.getApiNameFromBackend(targetAppPath); if (apiName && config.categories?.function) { for (const func of config.categories.function.functions) { if (func.apiAccess) { categoryInitializer.patchRegularFunctionApiAccess(targetAppPath, func.name, apiName, func.apiAccess.operations, context); + didPostPushPatching = true; } } } @@ -729,7 +731,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise // Run gen2-migration pre-deployment workflow (lock -> checkout -> generate) logger.info(`Running gen2-migration pre-deployment workflow for ${deploymentName}...`, context); - await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName); + await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName, didPostPushPatching); logger.info(`Successfully completed gen2-migration pre-deployment workflow for ${deploymentName}`, context); // Run app-specific post-generate script diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts b/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts index b32c2e20286..7d653c79e73 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts @@ -106,8 +106,9 @@ export class Gen2MigrationExecutor { * Enables deletion protection on DynamoDB tables, sets a deny-all stack policy, * and adds GEN2_MIGRATION_ENVIRONMENT_NAME env var to the Amplify app. */ - public async lock(appPath: string): Promise { - await this.executeStep('lock', appPath, ['--skip-validations']); + public async lock(appPath: string, skipValidations = false): Promise { + const extra = skipValidations ? ['--skip-validations'] : []; + await this.executeStep('lock', appPath, extra); } /** @@ -116,8 +117,9 @@ export class Gen2MigrationExecutor { * Creates/updates package.json with Gen2 dependencies, replaces the amplify * folder with Gen2 TypeScript definitions, and installs dependencies. */ - public async generate(appPath: string): Promise { - await this.executeStep('generate', appPath, ['--skip-validations']); + public async generate(appPath: string, skipValidations = false): Promise { + const extra = skipValidations ? ['--skip-validations'] : []; + await this.executeStep('generate', appPath, extra); } /** @@ -132,12 +134,12 @@ export class Gen2MigrationExecutor { /** * Run pre-deployment workflow: lock -> checkout gen2 branch -> generate */ - public async runPreDeploymentWorkflow(appPath: string, envName = 'main'): Promise { + public async runPreDeploymentWorkflow(appPath: string, envName = 'main', skipValidations = false): Promise { const context: LogContext = { operation: 'gen2-migration-workflow' }; this.logger.info('Starting pre-deployment workflow (lock -> checkout -> generate)...', context); // Lock on the main branch - await this.lock(appPath); + await this.lock(appPath, skipValidations); // Create and checkout gen2 branch before generate const gen2BranchName = `gen2-${envName}`; @@ -145,7 +147,7 @@ export class Gen2MigrationExecutor { await execa('git', ['checkout', '-b', gen2BranchName], { cwd: appPath }); // Generate Gen2 code - await this.generate(appPath); + await this.generate(appPath, skipValidations); this.logger.info('Pre-deployment workflow completed', context); } From fb7c075fcd5c0a131f34e21d97774a7742a796d1 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 15:13:27 -0400 Subject: [PATCH 3/8] fix: replace existsSync with try catch --- .../src/core/category-initializer.ts | 54 +++++++++++++------ 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts index ada9e86a9b1..36855517c80 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts @@ -672,10 +672,12 @@ export class CategoryInitializer { */ public discoverS3TriggerFunctionName(appPath: string, triggerNamePrefix: string): string | null { const functionDir = path.join(appPath, 'amplify', 'backend', 'function'); - if (!fs.existsSync(functionDir)) { + let entries: string[]; + try { + entries = fs.readdirSync(functionDir); + } catch { return null; } - const entries = fs.readdirSync(functionDir); return entries.find((name) => name.startsWith(triggerNamePrefix)) ?? null; } @@ -686,12 +688,16 @@ export class CategoryInitializer { */ private patchFunctionAppsyncPermission(appPath: string, functionName: string, context: LogContext): void { const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); - if (!fs.existsSync(cfnPath)) { + + let cfnContent: string; + try { + cfnContent = fs.readFileSync(cfnPath, 'utf-8'); + } catch { this.logger.warn(`CFN template not found for ${functionName}, skipping appsync:GraphQL patch`, context); return; } - const cfn = JSON.parse(fs.readFileSync(cfnPath, 'utf-8')) as CfnTemplate; + const cfn = JSON.parse(cfnContent) as CfnTemplate; cfn.Resources = cfn.Resources ?? {}; cfn.Resources.AppSyncGraphQLPolicy = { Type: 'AWS::IAM::Policy', @@ -741,11 +747,14 @@ export class CategoryInitializer { path.join(appPath, 'amplify', '#current-cloud-backend', 'backend-config.json'), ]; for (const configPath of configPaths) { - if (!fs.existsSync(configPath)) continue; - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as AmplifyBackendConfig; - if (config.function?.[functionName]) { - config.function[functionName].dependsOn = apiDependsOn; - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as AmplifyBackendConfig; + if (config.function?.[functionName]) { + config.function[functionName].dependsOn = apiDependsOn; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + } + } catch { + // File doesn't exist, skip } } this.logger.debug(`Patched backend-config.json: set ${functionName} dependsOn to api/${apiName}`, context); @@ -756,19 +765,24 @@ export class CategoryInitializer { path.join(appPath, 'amplify', '#current-cloud-backend', 'amplify-meta.json'), ]; for (const metaPath of metaPaths) { - if (!fs.existsSync(metaPath)) continue; - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as AmplifyBackendConfig; - if (meta.function?.[functionName]) { - meta.function[functionName].dependsOn = apiDependsOn; - fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); + try { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as AmplifyBackendConfig; + if (meta.function?.[functionName]) { + meta.function[functionName].dependsOn = apiDependsOn; + fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); + } + } catch { + // File doesn't exist, skip } } // Patch function-parameters.json const funcParamsPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, 'function-parameters.json'); let funcParams: Record = {}; - if (fs.existsSync(funcParamsPath)) { + try { funcParams = JSON.parse(fs.readFileSync(funcParamsPath, 'utf-8')) as Record; + } catch { + // File doesn't exist, start with empty object } funcParams.permissions = { api: { [apiName]: operations } }; delete funcParams.dependsOn; @@ -777,9 +791,15 @@ export class CategoryInitializer { // Patch CFN template: add API parameters and env vars const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); - if (!fs.existsSync(cfnPath)) return; - const cfn = JSON.parse(fs.readFileSync(cfnPath, 'utf-8')) as CfnTemplate; + let cfnContent: string; + try { + cfnContent = fs.readFileSync(cfnPath, 'utf-8'); + } catch { + return; + } + + const cfn = JSON.parse(cfnContent) as CfnTemplate; // Remove auth parameters if (cfn.Parameters) { From 380b278533be80918315ab3ee57e21baef2afdb6 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 15:20:47 -0400 Subject: [PATCH 4/8] fix: replace existsSync with try catch --- packages/amplify-gen2-migration-e2e-system/src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index d7ca3fe28c3..4d09a84d3f5 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -679,7 +679,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise // Run gen2-migration pre-deployment workflow (lock -> checkout -> generate) logger.info(`Running gen2-migration pre-deployment workflow for ${deploymentName}...`); - await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName); + await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName, didPostPushPatching); logger.info(`Successfully completed gen2-migration pre-deployment workflow for ${deploymentName}`); // Run app-specific post-generate script From 6ebd0b4a8e22c15b5933602ffe34fc046e7a3541 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 15:33:12 -0400 Subject: [PATCH 5/8] chore: remove context from logger calls --- .../src/cli.ts | 2 +- .../src/core/category-initializer.ts | 30 ++++++++----------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index 4d09a84d3f5..4b24079db3a 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -664,7 +664,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise if (apiName && config.categories?.function) { for (const func of config.categories.function.functions) { if (func.apiAccess) { - categoryInitializer.patchRegularFunctionApiAccess(targetAppPath, func.name, apiName, func.apiAccess.operations, context); + categoryInitializer.patchRegularFunctionApiAccess(targetAppPath, func.name, apiName, func.apiAccess.operations); didPostPushPatching = true; } } diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts index 36855517c80..03682d54645 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts @@ -108,7 +108,7 @@ export class CategoryInitializer { // Initialize regular functions that do NOT need API access (before API) if (categories.function) { - await this.initializeRegularFunctions(appPath, categories.function, result); + await this.initializeRegularFunctions(appPath, categories.function, false, result); } if (categories.storage) { @@ -121,7 +121,7 @@ export class CategoryInitializer { // Initialize regular functions that need API access (after API exists) if (categories.function && categories.api) { - await this.initializeRegularFunctions(appPath, categories.function, true, result, context); + await this.initializeRegularFunctions(appPath, categories.function, true, result); } // Initialize trigger functions after API (they need AppSync tables to exist) @@ -503,7 +503,7 @@ export class CategoryInitializer { if (entries.length > 0) { const [name, value] = entries[0]; settings.secretsConfig = { operation: 'add', name, value }; - this.logger.debug(`Adding secret ${name} to ${func.name}`, context); + this.logger.debug(`Adding secret ${name} to ${func.name}`); } } @@ -517,7 +517,7 @@ export class CategoryInitializer { resources: [apiName], operations: func.apiAccess.operations, }; - this.logger.debug(`Adding API access (${func.apiAccess.operations.join(', ')}) to ${func.name}`, context); + this.logger.debug(`Adding API access (${func.apiAccess.operations.join(', ')}) to ${func.name}`); } } @@ -525,7 +525,7 @@ export class CategoryInitializer { // Patch CFN template with appsync:GraphQL permission for functions that call back to AppSync if (withApiAccess && func.apiAccess) { - this.patchFunctionAppsyncPermission(appPath, func.name, context); + this.patchFunctionAppsyncPermission(appPath, func.name); } this.logger.debug(`Function ${func.name} added successfully`); @@ -686,14 +686,14 @@ export class CategoryInitializer { * Required for functions that make IAM-signed callbacks to AppSync. * Note: API env vars are wired by patchRegularFunctionApiAccess called from cli.ts. */ - private patchFunctionAppsyncPermission(appPath: string, functionName: string, context: LogContext): void { + private patchFunctionAppsyncPermission(appPath: string, functionName: string): void { const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); let cfnContent: string; try { cfnContent = fs.readFileSync(cfnPath, 'utf-8'); } catch { - this.logger.warn(`CFN template not found for ${functionName}, skipping appsync:GraphQL patch`, context); + this.logger.warn(`CFN template not found for ${functionName}, skipping appsync:GraphQL patch`); return; } @@ -719,20 +719,14 @@ export class CategoryInitializer { }; fs.writeFileSync(cfnPath, JSON.stringify(cfn, null, 2) + '\n', 'utf-8'); - this.logger.debug(`Patched ${functionName} CFN template with appsync:GraphQL permission`, context); + this.logger.debug(`Patched ${functionName} CFN template with appsync:GraphQL permission`); } /** * Patch backend-config.json and CFN template for a function with API access. * Replaces auth dependsOn with API dependsOn and adds API env vars. */ - public patchRegularFunctionApiAccess( - appPath: string, - functionName: string, - apiName: string, - operations: string[], - context: LogContext, - ): void { + public patchRegularFunctionApiAccess(appPath: string, functionName: string, apiName: string, operations: string[]): void { const apiDependsOn = [ { category: 'api', @@ -757,7 +751,7 @@ export class CategoryInitializer { // File doesn't exist, skip } } - this.logger.debug(`Patched backend-config.json: set ${functionName} dependsOn to api/${apiName}`, context); + this.logger.debug(`Patched backend-config.json: set ${functionName} dependsOn to api/${apiName}`); // Patch amplify-meta.json const metaPaths = [ @@ -787,7 +781,7 @@ export class CategoryInitializer { funcParams.permissions = { api: { [apiName]: operations } }; delete funcParams.dependsOn; fs.writeFileSync(funcParamsPath, JSON.stringify(funcParams, null, 2) + '\n', 'utf-8'); - this.logger.debug(`Patched function-parameters.json for ${functionName}`, context); + this.logger.debug(`Patched function-parameters.json for ${functionName}`); // Patch CFN template: add API parameters and env vars const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); @@ -835,7 +829,7 @@ export class CategoryInitializer { } fs.writeFileSync(cfnPath, JSON.stringify(cfn, null, 2) + '\n', 'utf-8'); - this.logger.debug(`Patched ${functionName} CFN template with API parameters and env vars`, context); + this.logger.debug(`Patched ${functionName} CFN template with API parameters and env vars`); } /** From 6a57920a101fec748b72d5e2a8e5c080569e0ea9 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 15:39:49 -0400 Subject: [PATCH 6/8] chore: remove context from logger calls in cli.ts --- packages/amplify-gen2-migration-e2e-system/src/cli.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index 4b24079db3a..a68c6b76e0a 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -606,7 +606,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise const functionMeta = amplifyMeta.function?.[func.name]?.output as Record | undefined; const lambdaName = functionMeta?.Name; if (lambdaName) { - logger.debug(`Updating ${lambdaName} env vars with API endpoint`, context); + logger.debug(`Updating ${lambdaName} env vars with API endpoint`); const upper = postPushApiName.toUpperCase(); // Get existing env vars first, then merge (update-function-configuration replaces all vars) const getLambdaResult = await execa('aws', [ @@ -642,7 +642,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise '--profile', profile, ]); - logger.info(`Updated ${func.name} Lambda with API env vars`, context); + logger.info(`Updated ${func.name} Lambda with API env vars`); } } } @@ -692,7 +692,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise logger.info(`Gen2 generated code committed`); // Reinstall dependencies to pick up Gen2 deps (ampx, @aws-amplify/backend, etc.) - logger.info(`Reinstalling dependencies before Gen2 deployment...`, context); + logger.info(`Reinstalling dependencies before Gen2 deployment...`); await execa('npm', ['install'], { cwd: targetAppPath }); // Deploy Gen2 using ampx sandbox From 2de959b13cdd340ccb7239f9754e596008f3e44d Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 19:53:12 -0400 Subject: [PATCH 7/8] chore: adding modified logic --- .../product-catalog/migration-config.json | 3 + .../product-catalog/post-generate.ts | 200 ++++++++++++ .../product-catalog/post-refactor.ts | 70 +++++ .../src/cli.ts | 122 +------- .../src/core/category-initializer.ts | 287 ++++-------------- 5 files changed, 336 insertions(+), 346 deletions(-) create mode 100644 amplify-migration-apps/product-catalog/post-generate.ts create mode 100644 amplify-migration-apps/product-catalog/post-refactor.ts diff --git a/amplify-migration-apps/product-catalog/migration-config.json b/amplify-migration-apps/product-catalog/migration-config.json index dd1d348bf56..9038b053938 100644 --- a/amplify-migration-apps/product-catalog/migration-config.json +++ b/amplify-migration-apps/product-catalog/migration-config.json @@ -36,6 +36,9 @@ "name": "lowstockproducts", "runtime": "nodejs", "template": "hello-world", + "environment": { + "LOW_STOCK_THRESHOLD": "5" + }, "secrets": { "PRODUCT_CATALOG_SECRET": "product-catalog-secret-value" }, diff --git a/amplify-migration-apps/product-catalog/post-generate.ts b/amplify-migration-apps/product-catalog/post-generate.ts new file mode 100644 index 00000000000..a5c30c611ea --- /dev/null +++ b/amplify-migration-apps/product-catalog/post-generate.ts @@ -0,0 +1,200 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for product-catalog app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Convert lowstockproducts function from CommonJS to ESM + update secret fetching + * 2. Convert S3 trigger function from CommonJS to ESM + * 3. Update lowstockproducts/resource.ts to use secret() for PRODUCT_CATALOG_SECRET + * 4. Update frontend import from amplifyconfiguration.json to amplify_outputs.json + */ + +import fs from 'fs/promises'; +import path from 'path'; + +interface PostGenerateOptions { + appPath: string; + envName?: string; +} + +async function convertLowStockToESM(appPath: string): Promise { + const handlerPath = path.join(appPath, 'amplify', 'function', 'lowstockproducts', 'index.js'); + + console.log(`Converting lowstockproducts to ESM in ${handlerPath}...`); + + let content: string; + try { + content = await fs.readFile(handlerPath, 'utf-8'); + } catch { + console.log(' index.js not found, skipping'); + return; + } + + // Convert exports.handler to ESM export + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + // Replace SSM secret fetching with env var read: + // const secretValue = await fetchSecret(); + // becomes: + // const secretValue = process.env['PRODUCT_CATALOG_SECRET']; + updated = updated.replace( + /const secretValue = await fetchSecret\(\);/g, + "const secretValue = process.env['PRODUCT_CATALOG_SECRET'];", + ); + + if (updated === content) { + console.log(' No changes needed, skipping'); + return; + } + + await fs.writeFile(handlerPath, updated, 'utf-8'); + console.log(' Converted to ESM and updated secret fetching'); +} + +async function convertS3TriggerToESM(appPath: string): Promise { + // Find the S3 trigger function directory (name varies per deployment) + const storagePath = path.join(appPath, 'amplify', 'storage'); + + let triggerDirs: string[]; + try { + const entries = await fs.readdir(storagePath, { withFileTypes: true }); + triggerDirs = entries + .filter((e) => e.isDirectory() && e.name.startsWith('S3Trigger')) + .map((e) => e.name); + } catch { + console.log(' amplify/storage/ not found, skipping'); + return; + } + + for (const triggerDir of triggerDirs) { + const handlerPath = path.join(storagePath, triggerDir, 'index.js'); + + console.log(`Converting ${triggerDir} to ESM in ${handlerPath}...`); + + let content: string; + try { + content = await fs.readFile(handlerPath, 'utf-8'); + } catch { + console.log(' index.js not found, skipping'); + continue; + } + + // Convert exports.handler = async function (event) { to export async function handler(event) { + let updated = content.replace( + /exports\.handler\s*=\s*async\s*function\s*\((\w*)\)\s*\{/g, + 'export async function handler($1) {', + ); + + // Also handle arrow function pattern + updated = updated.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + if (updated === content) { + console.log(' No CommonJS exports found, skipping'); + continue; + } + + await fs.writeFile(handlerPath, updated, 'utf-8'); + console.log(' Converted to ESM syntax'); + } +} + + +async function updateLowStockResourceTs(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'function', 'lowstockproducts', 'resource.ts'); + + console.log(`Updating lowstockproducts/resource.ts to use secret()...`); + + let content: string; + try { + content = await fs.readFile(resourcePath, 'utf-8'); + } catch { + console.log(' resource.ts not found, skipping'); + return; + } + + // Add secret import if not present + let updated = content.replace( + /import \{ defineFunction \} from ["']@aws-amplify\/backend["'];/, + 'import { defineFunction, secret } from "@aws-amplify/backend";', + ); + + // Replace the SSM path with secret() call + // The generated code has something like: + // PRODUCT_CATALOG_SECRET: "/amplify/..." + // Replace with: + // PRODUCT_CATALOG_SECRET: secret("PRODUCT_CATALOG_SECRET") + updated = updated.replace( + /PRODUCT_CATALOG_SECRET:\s*\n?\s*['"][^'"]+['"]/, + 'PRODUCT_CATALOG_SECRET: secret("PRODUCT_CATALOG_SECRET")', + ); + + if (updated === content) { + console.log(' No changes needed, skipping'); + return; + } + + await fs.writeFile(resourcePath, updated, 'utf-8'); + console.log(' Updated to use secret()'); +} + +async function updateFrontendConfig(appPath: string): Promise { + const mainPath = path.join(appPath, 'src', 'main.tsx'); + + console.log(`Updating frontend config import in ${mainPath}...`); + + let content: string; + try { + content = await fs.readFile(mainPath, 'utf-8'); + } catch { + console.log(' main.tsx not found, skipping'); + return; + } + + // Change: import amplifyconfig from './amplifyconfiguration.json'; + // To: import amplifyconfig from '../amplify_outputs.json'; + const updated = content.replace( + /from\s*["']\.\/amplifyconfiguration\.json["']/g, + "from '../amplify_outputs.json'", + ); + + if (updated === content) { + console.log(' No amplifyconfiguration.json import found, skipping'); + return; + } + + await fs.writeFile(mainPath, updated, 'utf-8'); + console.log(' Updated import to amplify_outputs.json'); +} + +export async function postGenerate(options: PostGenerateOptions): Promise { + const { appPath } = options; + + console.log(`Running post-generate for product-catalog at ${appPath}`); + console.log(''); + + await convertLowStockToESM(appPath); + await convertS3TriggerToESM(appPath); + await updateLowStockResourceTs(appPath); + await updateFrontendConfig(appPath); + + console.log(''); + console.log('Post-generate completed'); +} + +// CLI entry point +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + const appPath = process.argv[2] || process.cwd(); + const envName = process.argv[3] || 'main'; + + postGenerate({ appPath, envName }).catch((error) => { + console.error('Post-generate failed:', error); + process.exit(1); + }); +} diff --git a/amplify-migration-apps/product-catalog/post-refactor.ts b/amplify-migration-apps/product-catalog/post-refactor.ts new file mode 100644 index 00000000000..da59391abeb --- /dev/null +++ b/amplify-migration-apps/product-catalog/post-refactor.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env npx ts-node +/** + * Post-refactor script for product-catalog app. + * + * Applies manual edits required after `amplify gen2-migration refactor`: + * 1. Uncomment s3Bucket.bucketName in amplify/backend.ts to preserve the original bucket name. + */ + +import fs from 'fs/promises'; +import path from 'path'; + +interface PostRefactorOptions { + appPath: string; + envName?: string; +} + +async function uncommentBucketName(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + + console.log(`Uncommenting s3Bucket.bucketName in ${backendPath}...`); + + let content: string; + try { + content = await fs.readFile(backendPath, 'utf-8'); + } catch { + console.log(' backend.ts not found, skipping'); + return; + } + + // The generated code has: + // // s3Bucket.bucketName = '...'; + // Uncomment it: + // s3Bucket.bucketName = '...'; + const updated = content.replace( + /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];)/, + '$1', + ); + + if (updated === content) { + console.log(' No commented bucketName found, skipping'); + return; + } + + await fs.writeFile(backendPath, updated, 'utf-8'); + console.log(' Uncommented s3Bucket.bucketName'); +} + +export async function postRefactor(options: PostRefactorOptions): Promise { + const { appPath } = options; + + console.log(`Running post-refactor for product-catalog at ${appPath}`); + console.log(''); + + await uncommentBucketName(appPath); + + console.log(''); + console.log('Post-refactor completed'); +} + +// CLI entry point +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + const appPath = process.argv[2] || process.cwd(); + const envName = process.argv[3] || 'main'; + + postRefactor({ appPath, envName }).catch((error) => { + console.error('Post-refactor failed:', error); + process.exit(1); + }); +} diff --git a/packages/amplify-gen2-migration-e2e-system/src/cli.ts b/packages/amplify-gen2-migration-e2e-system/src/cli.ts index a68c6b76e0a..ab905c5ca28 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/cli.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/cli.ts @@ -441,39 +441,6 @@ async function runGen2TestScript(targetAppPath: string, migrationTargetPath: str logger.info(`${testScriptName} completed successfully`); } -/** - * Resolve custom-roles.json for apps that use AppSync admin roles. - * Replaces the ${appId} placeholder with the deployment name and adds - * Lambda execution role prefixes so IAM-signed Lambda calls are authorized. - */ -async function resolveCustomRolesJson(sourceAppPath: string, targetAppPath: string, deploymentName: string): Promise { - const customRolesSource = path.join(sourceAppPath, 'custom-roles.json'); - if (!fs.existsSync(customRolesSource)) { - logger.debug('No custom-roles.json found in app source, skipping'); - return; - } - - const apiDir = path.join(targetAppPath, 'amplify', 'backend', 'api'); - if (!fs.existsSync(apiDir)) return; - const apiEntries = fs.readdirSync(apiDir); - if (apiEntries.length === 0) return; - const apiName = apiEntries[0]; - - const projectConfigPath = path.join(targetAppPath, 'amplify', '.config', 'project-config.json'); - let resolvedName = deploymentName; - if (fs.existsSync(projectConfigPath)) { - const projectConfig = JSON.parse(fs.readFileSync(projectConfigPath, 'utf-8')) as { projectName?: string }; - if (projectConfig.projectName) resolvedName = projectConfig.projectName; - } - - const customRoles = { - adminRoleNames: [`amplify-${resolvedName}`, `${resolvedName}LambdaRole`, 'amplifyAuthauthenticatedU'], - }; - const targetPath = path.join(apiDir, apiName, 'custom-roles.json'); - fs.writeFileSync(targetPath, JSON.stringify(customRoles, null, 2) + '\n', 'utf-8'); - logger.info(`Resolved custom-roles.json with deployment name: ${resolvedName}`); -} - /** * Spawn the amplify CLI directly to run amplify push --yes. * @@ -577,99 +544,16 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise logger.debug(`No configure.sh found for ${deploymentName}, skipping`); } - // Resolve custom-roles.json if the app has one (must happen before push) - await resolveCustomRolesJson(sourceAppPath, targetAppPath, deploymentName); - // Push the initialized app to AWS logger.info(`Pushing ${deploymentName} to AWS...`); await amplifyPush(targetAppPath); logger.info(`Successfully pushed ${deploymentName} to AWS`); - // Update deployed Lambda env vars for functions with API access. - // addFunction with additionalPermissions sets dependsOn to auth, so the deployed - // Lambda gets AUTH_* env vars but not API_* env vars. We read the GraphQL endpoint - // from amplify-meta.json and update the Lambda configuration directly. - if (config.categories?.function && config.categories?.api) { - const postPushApiName = categoryInitializer.getApiNameFromBackend(targetAppPath); - if (postPushApiName) { - const amplifyMetaPath = path.join(targetAppPath, 'amplify', 'backend', 'amplify-meta.json'); - if (fs.existsSync(amplifyMetaPath)) { - const amplifyMeta = JSON.parse(fs.readFileSync(amplifyMetaPath, 'utf-8')) as Record< - string, - Record> - >; - const apiMeta = amplifyMeta.api?.[postPushApiName]?.output as Record | undefined; - if (apiMeta?.GraphQLAPIEndpointOutput) { - const region = (amplifyMeta.providers?.awscloudformation?.Region as string) || 'us-east-1'; - for (const func of config.categories.function.functions) { - if (func.apiAccess) { - const functionMeta = amplifyMeta.function?.[func.name]?.output as Record | undefined; - const lambdaName = functionMeta?.Name; - if (lambdaName) { - logger.debug(`Updating ${lambdaName} env vars with API endpoint`); - const upper = postPushApiName.toUpperCase(); - // Get existing env vars first, then merge (update-function-configuration replaces all vars) - const getLambdaResult = await execa('aws', [ - 'lambda', - 'get-function-configuration', - '--function-name', - lambdaName, - '--region', - region, - '--profile', - profile, - '--query', - 'Environment.Variables', - '--output', - 'json', - ]); - const existingVars = JSON.parse(getLambdaResult.stdout || '{}') as Record; - const mergedVars = { - ...existingVars, - [`API_${upper}_GRAPHQLAPIIDOUTPUT`]: apiMeta.GraphQLAPIIdOutput || '', - [`API_${upper}_GRAPHQLAPIENDPOINTOUTPUT`]: apiMeta.GraphQLAPIEndpointOutput, - [`API_${upper}_GRAPHQLAPIKEYOUTPUT`]: apiMeta.GraphQLAPIKeyOutput || '', - }; - await execa('aws', [ - 'lambda', - 'update-function-configuration', - '--function-name', - lambdaName, - '--environment', - JSON.stringify({ Variables: mergedVars }), - '--region', - region, - '--profile', - profile, - ]); - logger.info(`Updated ${func.name} Lambda with API env vars`); - } - } - } - } - } - } - } - // Run gen1 test script to validate the Gen1 deployment logger.info(`Running gen1 test script (post-push) for ${deploymentName}...`); await runGen1TestScript(targetAppPath, migrationTargetPath, sourceAppsBasePath); logger.info(`Gen1 test script passed (post-push) for ${deploymentName}`); - // Patch backend-config.json and CFN templates for functions with API access. - // Must happen AFTER push (which needs auth dependsOn) but BEFORE git commit - // (so gen2-migration generate sees api dependsOn in the committed state). - let didPostPushPatching = false; - const apiName = categoryInitializer.getApiNameFromBackend(targetAppPath); - if (apiName && config.categories?.function) { - for (const func of config.categories.function.functions) { - if (func.apiAccess) { - categoryInitializer.patchRegularFunctionApiAccess(targetAppPath, func.name, apiName, func.apiAccess.operations); - didPostPushPatching = true; - } - } - } - // Initialize git repo and commit the Gen1 state logger.info(`Initializing git repository for ${deploymentName}...`); await execa('git', ['init'], { cwd: targetAppPath }); @@ -679,7 +563,7 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise // Run gen2-migration pre-deployment workflow (lock -> checkout -> generate) logger.info(`Running gen2-migration pre-deployment workflow for ${deploymentName}...`); - await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName, didPostPushPatching); + await gen2MigrationExecutor.runPreDeploymentWorkflow(targetAppPath, envName); logger.info(`Successfully completed gen2-migration pre-deployment workflow for ${deploymentName}`); // Run app-specific post-generate script @@ -691,10 +575,6 @@ async function initializeAppFromCLI(params: InitializeAppFromCLIParams): Promise await execa('git', ['commit', '-m', 'feat: gen2 migration generate'], { cwd: targetAppPath }); logger.info(`Gen2 generated code committed`); - // Reinstall dependencies to pick up Gen2 deps (ampx, @aws-amplify/backend, etc.) - logger.info(`Reinstalling dependencies before Gen2 deployment...`); - await execa('npm', ['install'], { cwd: targetAppPath }); - // Deploy Gen2 using ampx sandbox logger.info(`Deploying Gen2 app using ampx sandbox for ${deploymentName}...`); const gen2BranchName = `gen2-${envName}`; diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts index 03682d54645..b92e5b5ad50 100644 --- a/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts +++ b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts @@ -30,30 +30,11 @@ import { addKinesis, updateSchema, } from '@aws-amplify/amplify-e2e-core'; +import type { CoreFunctionSettings } from '@aws-amplify/amplify-e2e-core'; import * as fs from 'fs'; import * as path from 'path'; import { Logger } from '../utils/logger'; -/** Minimal shape of a CloudFormation template used for CFN patching. */ -interface CfnTemplate { - Parameters?: Record>; - Resources?: Record & { - LambdaFunction?: { - Properties?: { - Environment?: { - Variables?: Record; - }; - }; - }; - }; -} - -/** Minimal shape of backend-config.json / amplify-meta.json used for patching. */ -interface AmplifyBackendConfig { - api?: Record; - function?: Record; -} - export interface CategoryInitializerOptions { appPath: string; config: AppConfiguration; @@ -74,7 +55,6 @@ export class CategoryInitializer { */ async initializeCategories(options: CategoryInitializerOptions): Promise { const { appPath, config, deploymentName } = options; - const result: InitializeCategoriesResult = { initializedCategories: [], skippedCategories: [], @@ -89,15 +69,15 @@ export class CategoryInitializer { return result; } - // Initialize categories in the correct order: + // Initialize categories in the order matching the manual CLI workflow: // 1. Auth first (other categories may depend on it) // 2. Analytics before functions (functions may reference analytics resources) - // 3. Regular functions WITHOUT API access before storage/API + // 3. GraphQL API (must exist before functions that need API access) // 4. Storage (may have triggers that reference functions) - // 5. GraphQL API (creates AppSync tables that trigger functions may reference) - // 6. Regular functions WITH API access (need API to exist for env vars) - // 7. Trigger functions (need AppSync/DynamoDB tables to exist) - // 8. REST API last (needs functions to exist) + // 5. Regular functions (non-trigger) after API so they can request API access + // 6. Trigger functions (need AppSync/DynamoDB tables to exist) + // 7. REST API last (needs functions to exist) + if (categories.auth) { await this.initializeAuthCategory(appPath, categories.auth, result); } @@ -106,22 +86,17 @@ export class CategoryInitializer { await this.initializeAnalyticsCategory(appPath, categories.analytics, result); } - // Initialize regular functions that do NOT need API access (before API) - if (categories.function) { - await this.initializeRegularFunctions(appPath, categories.function, false, result); + if (categories.api) { + await this.initializeApiCategory(appPath, categories.api, categories.function, result); } if (categories.storage) { await this.initializeStorageCategory(appPath, categories.storage, categories.auth, result); } - if (categories.api) { - await this.initializeApiCategory(appPath, categories.api, categories.function, result); - } - - // Initialize regular functions that need API access (after API exists) - if (categories.function && categories.api) { - await this.initializeRegularFunctions(appPath, categories.function, true, result); + // Initialize regular (non-trigger) functions after API so they can request API access + if (categories.function) { + await this.initializeRegularFunctions(appPath, categories.function, result); } // Initialize trigger functions after API (they need AppSync tables to exist) @@ -135,7 +110,6 @@ export class CategoryInitializer { } this.logger.info(`Category initialization complete. Initialized: ${result.initializedCategories.join(', ') || 'none'}`); - return result; } @@ -375,6 +349,7 @@ export class CategoryInitializer { // Check if guest access is configured for any bucket const hasGuestAccess = storageConfig.buckets.some((bucket) => bucket.access.includes('guest') || bucket.access.includes('public')); + // Check if triggers are configured const hasTriggers = storageConfig.triggers && storageConfig.triggers.length > 0; @@ -465,18 +440,15 @@ export class CategoryInitializer { /** * Initialize regular (non-trigger) Lambda functions. - * When withApiAccess is false, creates functions that don't need API access. - * When withApiAccess is true, creates functions that need API access (must be called after API init). + * Passes additionalPermissions, environmentVariables, and secretsConfig + * when the migration config specifies apiAccess, environment, or secrets. */ private async initializeRegularFunctions( appPath: string, functionConfig: FunctionConfiguration, - withApiAccess: boolean, result: InitializeCategoriesResult, ): Promise { - const regularFunctions = functionConfig.functions - .filter((f) => !f.trigger) - .filter((f) => (withApiAccess ? !!f.apiAccess : !f.apiAccess)); + const regularFunctions = functionConfig.functions.filter((f) => !f.trigger); if (regularFunctions.length === 0) { this.logger.debug('No regular functions to initialize'); @@ -488,46 +460,61 @@ export class CategoryInitializer { try { for (const func of regularFunctions) { this.logger.debug(`Adding function: ${func.name}`); - const runtime = this.mapRuntime(func.runtime); const template = this.mapTemplate(func.template); - const settings: Record = { + // Build settings matching the manual CLI prompts from the README + const settings: CoreFunctionSettings = { name: func.name, functionTemplate: template, }; - // Wire secrets (addFunction supports one at a time) - if (func.secrets) { - const entries = Object.entries(func.secrets); - if (entries.length > 0) { - const [name, value] = entries[0]; - settings.secretsConfig = { operation: 'add', name, value }; - this.logger.debug(`Adding secret ${name} to ${func.name}`); - } - } - - // Wire API access permissions (only when withApiAccess=true, API must exist) - if (withApiAccess && func.apiAccess) { + // API access: "Do you want to access other resources?" → Yes → api → operations + if (func.apiAccess) { const apiName = this.getApiNameFromBackend(appPath); if (apiName) { + const operations = func.apiAccess.operations; // e.g. ['Query'] + // The choices list must match the order the CLI presents categories. + // The CLI reads Object.keys(amplifyMeta) filtered to exclude 'providers' and 'predictions'. + // We read the actual category order from amplify-meta.json to ensure correct multiSelect positioning. + const categoryChoices = this.getCategoryChoicesFromMeta(appPath); settings.additionalPermissions = { permissions: ['api'], - choices: ['api', 'auth', 'function', 'storage'], + choices: categoryChoices, resources: [apiName], - operations: func.apiAccess.operations, + operations, }; - this.logger.debug(`Adding API access (${func.apiAccess.operations.join(', ')}) to ${func.name}`); + this.logger.debug( + `Function ${func.name}: API access configured (${operations.join(', ')}) with choices: [${categoryChoices.join(', ')}]`, + ); + } else { + this.logger.warn(`Function ${func.name} has apiAccess but no API found in backend`); } } - await addFunction(appPath, settings as { name: string; functionTemplate: string }, runtime); + // Environment variables: "Do you want to configure environment variables?" → Yes + if (func.environment && Object.keys(func.environment).length > 0) { + const firstKey = Object.keys(func.environment)[0]; + settings.environmentVariables = { + key: firstKey, + value: func.environment[firstKey], + }; + this.logger.debug(`Function ${func.name}: env var ${firstKey}=${func.environment[firstKey]}`); + } - // Patch CFN template with appsync:GraphQL permission for functions that call back to AppSync - if (withApiAccess && func.apiAccess) { - this.patchFunctionAppsyncPermission(appPath, func.name); + // Secrets: "Do you want to configure secret values?" → Yes + if (func.secrets && Object.keys(func.secrets).length > 0) { + const firstSecretName = Object.keys(func.secrets)[0]; + settings.secretsConfig = { + operation: 'add', + name: firstSecretName, + value: func.secrets[firstSecretName], + }; + this.logger.debug(`Function ${func.name}: secret ${firstSecretName} configured`); } + await addFunction(appPath, settings, runtime); + this.logger.debug(`Function ${func.name} added successfully`); } @@ -561,7 +548,6 @@ export class CategoryInitializer { try { for (const func of triggerFunctions) { this.logger.debug(`Adding trigger function: ${func.name}`); - const runtime = this.mapRuntime(func.runtime); const triggerType = func.trigger?.type; @@ -652,7 +638,7 @@ export class CategoryInitializer { /** * Get the API name from the amplify backend configuration */ - public getApiNameFromBackend(appPath: string): string | null { + private getApiNameFromBackend(appPath: string): string | null { try { const backendConfigPath = path.join(appPath, 'amplify', 'backend', 'backend-config.json'); if (fs.existsSync(backendConfigPath)) { @@ -667,169 +653,20 @@ export class CategoryInitializer { } /** - * Discover the actual S3 trigger function name from the backend directory. - * The trigger name in migration-config.json is a prefix; the actual function has a random suffix. - */ - public discoverS3TriggerFunctionName(appPath: string, triggerNamePrefix: string): string | null { - const functionDir = path.join(appPath, 'amplify', 'backend', 'function'); - let entries: string[]; - try { - entries = fs.readdirSync(functionDir); - } catch { - return null; - } - return entries.find((name) => name.startsWith(triggerNamePrefix)) ?? null; - } - - /** - * Patch a function's CFN template to add appsync:GraphQL permission. - * Required for functions that make IAM-signed callbacks to AppSync. - * Note: API env vars are wired by patchRegularFunctionApiAccess called from cli.ts. + * Read category keys from amplify-meta.json in the order the CLI presents them. + * The CLI uses Object.keys(amplifyMeta) filtered to exclude 'providers' and 'predictions'. */ - private patchFunctionAppsyncPermission(appPath: string, functionName: string): void { - const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); - - let cfnContent: string; + private getCategoryChoicesFromMeta(appPath: string): string[] { try { - cfnContent = fs.readFileSync(cfnPath, 'utf-8'); - } catch { - this.logger.warn(`CFN template not found for ${functionName}, skipping appsync:GraphQL patch`); - return; - } - - const cfn = JSON.parse(cfnContent) as CfnTemplate; - cfn.Resources = cfn.Resources ?? {}; - cfn.Resources.AppSyncGraphQLPolicy = { - Type: 'AWS::IAM::Policy', - Properties: { - PolicyName: 'appsync-graphql-policy', - Roles: [{ Ref: 'LambdaExecutionRole' }], - PolicyDocument: { - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: ['appsync:GraphQL'], - Resource: { 'Fn::Sub': 'arn:aws:appsync:${AWS::Region}:${AWS::AccountId}:apis/*' }, - }, - ], - }, - }, - DependsOn: ['LambdaExecutionRole'], - }; - - fs.writeFileSync(cfnPath, JSON.stringify(cfn, null, 2) + '\n', 'utf-8'); - this.logger.debug(`Patched ${functionName} CFN template with appsync:GraphQL permission`); - } - - /** - * Patch backend-config.json and CFN template for a function with API access. - * Replaces auth dependsOn with API dependsOn and adds API env vars. - */ - public patchRegularFunctionApiAccess(appPath: string, functionName: string, apiName: string, operations: string[]): void { - const apiDependsOn = [ - { - category: 'api', - resourceName: apiName, - attributes: ['GraphQLAPIIdOutput', 'GraphQLAPIEndpointOutput', 'GraphQLAPIKeyOutput'], - }, - ]; - - // Patch backend-config.json - const configPaths = [ - path.join(appPath, 'amplify', 'backend', 'backend-config.json'), - path.join(appPath, 'amplify', '#current-cloud-backend', 'backend-config.json'), - ]; - for (const configPath of configPaths) { - try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as AmplifyBackendConfig; - if (config.function?.[functionName]) { - config.function[functionName].dependsOn = apiDependsOn; - fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8'); - } - } catch { - // File doesn't exist, skip - } - } - this.logger.debug(`Patched backend-config.json: set ${functionName} dependsOn to api/${apiName}`); - - // Patch amplify-meta.json - const metaPaths = [ - path.join(appPath, 'amplify', 'backend', 'amplify-meta.json'), - path.join(appPath, 'amplify', '#current-cloud-backend', 'amplify-meta.json'), - ]; - for (const metaPath of metaPaths) { - try { - const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as AmplifyBackendConfig; - if (meta.function?.[functionName]) { - meta.function[functionName].dependsOn = apiDependsOn; - fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n', 'utf-8'); - } - } catch { - // File doesn't exist, skip + const metaPath = path.join(appPath, 'amplify', 'backend', 'amplify-meta.json'); + if (fs.existsSync(metaPath)) { + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Record; + return Object.keys(meta).filter((k) => k !== 'providers' && k !== 'predictions'); } - } - - // Patch function-parameters.json - const funcParamsPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, 'function-parameters.json'); - let funcParams: Record = {}; - try { - funcParams = JSON.parse(fs.readFileSync(funcParamsPath, 'utf-8')) as Record; - } catch { - // File doesn't exist, start with empty object - } - funcParams.permissions = { api: { [apiName]: operations } }; - delete funcParams.dependsOn; - fs.writeFileSync(funcParamsPath, JSON.stringify(funcParams, null, 2) + '\n', 'utf-8'); - this.logger.debug(`Patched function-parameters.json for ${functionName}`); - - // Patch CFN template: add API parameters and env vars - const cfnPath = path.join(appPath, 'amplify', 'backend', 'function', functionName, `${functionName}-cloudformation-template.json`); - - let cfnContent: string; - try { - cfnContent = fs.readFileSync(cfnPath, 'utf-8'); + return ['api', 'auth', 'function', 'storage']; } catch { - return; - } - - const cfn = JSON.parse(cfnContent) as CfnTemplate; - - // Remove auth parameters - if (cfn.Parameters) { - for (const paramName of Object.keys(cfn.Parameters)) { - if (paramName.startsWith('auth')) { - delete cfn.Parameters[paramName]; - } - } + return ['api', 'auth', 'function', 'storage']; } - - // Add API CFN parameters - cfn.Parameters = cfn.Parameters ?? {}; - const apiParams: Record = { - [`api${apiName}GraphQLAPIIdOutput`]: `api${apiName}GraphQLAPIIdOutput`, - [`api${apiName}GraphQLAPIEndpointOutput`]: `api${apiName}GraphQLAPIEndpointOutput`, - [`api${apiName}GraphQLAPIKeyOutput`]: `api${apiName}GraphQLAPIKeyOutput`, - }; - for (const [paramName, defaultValue] of Object.entries(apiParams)) { - cfn.Parameters[paramName] = { Type: 'String', Default: defaultValue }; - } - - // Remove auth env vars and add API env vars - const envVars = cfn.Resources?.LambdaFunction?.Properties?.Environment?.Variables; - if (envVars) { - for (const envKey of Object.keys(envVars as Record)) { - if (envKey.startsWith('AUTH_')) { - delete (envVars as Record)[envKey]; - } - } - envVars[`API_${apiName.toUpperCase()}_GRAPHQLAPIIDOUTPUT`] = { Ref: `api${apiName}GraphQLAPIIdOutput` }; - envVars[`API_${apiName.toUpperCase()}_GRAPHQLAPIENDPOINTOUTPUT`] = { Ref: `api${apiName}GraphQLAPIEndpointOutput` }; - envVars[`API_${apiName.toUpperCase()}_GRAPHQLAPIKEYOUTPUT`] = { Ref: `api${apiName}GraphQLAPIKeyOutput` }; - } - - fs.writeFileSync(cfnPath, JSON.stringify(cfn, null, 2) + '\n', 'utf-8'); - this.logger.debug(`Patched ${functionName} CFN template with API parameters and env vars`); } /** From a4d1c2fb8c5d81089c3287fd6f0be26103aca887 Mon Sep 17 00:00:00 2001 From: sanjanaravikumar-az Date: Thu, 2 Apr 2026 19:56:56 -0400 Subject: [PATCH 8/8] Delete packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts --- .../src/core/gen2-migration-executor.ts | 235 ------------------ 1 file changed, 235 deletions(-) delete mode 100644 packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts diff --git a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts b/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts deleted file mode 100644 index c315d844d8f..00000000000 --- a/packages/amplify-gen2-migration-e2e-system/src/core/gen2-migration-executor.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Gen2 Migration Executor - * - * Executes gen2-migration CLI commands (lock, generate, refactor) - * from the amplify-cli package to migrate Gen1 apps to Gen2. - */ - -import execa from 'execa'; -import os from 'os'; -import { getCLIPath } from '@aws-amplify/amplify-e2e-core'; -import { Logger } from '../utils/logger'; - -/** - * Available gen2-migration steps - */ -export type Gen2MigrationStep = 'lock' | 'generate' | 'refactor'; - -/** - * Options for Gen2MigrationExecutor - */ -export interface Gen2MigrationExecutorOptions { - /** AWS profile to use for CLI commands */ - profile?: string; -} - -/** - * Executor for gen2-migration CLI commands. - * - * The migration workflow consists of: - * 1. lock - Lock the Gen1 environment to prevent updates during migration - * 2. generate - Generate Gen2 code from Gen1 configuration - * 3. refactor - Move stateful resources from Gen1 to Gen2 stacks - */ -export class Gen2MigrationExecutor { - private readonly amplifyPath: string; - private readonly profile?: string; - - constructor(private readonly logger: Logger, options?: Gen2MigrationExecutorOptions) { - this.amplifyPath = getCLIPath(true); - this.profile = options?.profile; - } - - /** - * Execute a gen2-migration step. Throws on failure. - */ - private async executeStep(step: Gen2MigrationStep, appPath: string, extraArgs: string[] = []): Promise { - this.logger.info(`Executing gen2-migration ${step}...`); - this.logger.debug(`App path: ${appPath}`); - this.logger.debug(`Using amplify CLI at: ${this.amplifyPath}`); - - const args = ['gen2-migration', step, '--yes', ...extraArgs]; - this.logger.debug(`Command: ${this.amplifyPath} ${args.join(' ')}`); - - const startTime = Date.now(); - - // Set AWS_PROFILE env var if profile is specified - const env = this.profile ? { ...process.env, AWS_PROFILE: this.profile } : undefined; - - const result = await execa(this.amplifyPath, args, { - cwd: appPath, - stdio: 'inherit', - reject: false, - env, - }); - - const durationMs = Date.now() - startTime; - - if (result.exitCode !== 0) { - const errorMessage = result.stderr || result.stdout || `Exit code ${result.exitCode}`; - this.logger.error(`gen2-migration ${step} failed: ${errorMessage}`, undefined); - throw new Error(`gen2-migration ${step} failed: ${errorMessage}`); - } - - this.logger.info(`gen2-migration ${step} completed (${durationMs}ms)`); - } - - /** - * Lock the Gen1 environment. - * - * Enables deletion protection on DynamoDB tables, sets a deny-all stack policy, - * and adds GEN2_MIGRATION_ENVIRONMENT_NAME env var to the Amplify app. - */ - public async lock(appPath: string, skipValidations = false): Promise { - const extra = skipValidations ? ['--skip-validations'] : []; - await this.executeStep('lock', appPath, extra); - } - - /** - * Generate Gen2 code from Gen1 configuration. - * - * Creates/updates package.json with Gen2 dependencies, replaces the amplify - * folder with Gen2 TypeScript definitions, and installs dependencies. - */ - - public async generate(appPath: string, skipValidations = false): Promise { - const extra = skipValidations ? ['--skip-validations'] : []; - await this.executeStep('generate', appPath, extra); - this.logger.info('Installing dependencies..'); - await execa('npm', ['install'], { cwd: appPath }); - } - - - /** - * Move stateful resources from Gen1 to Gen2 stacks. - * - * Requires Gen2 deployment to be complete before running. - */ - public async refactor(appPath: string, gen2StackName: string): Promise { - await this.executeStep('refactor', appPath, ['--to', gen2StackName]); - } - - /** - * Run pre-deployment workflow: lock -> checkout gen2 branch -> generate - */ - public async runPreDeploymentWorkflow(appPath: string, envName = 'main', skipValidations = false): Promise { - this.logger.info('Starting pre-deployment workflow (lock -> checkout -> generate)...'); - - // Lock on the main branch - await this.lock(appPath, skipValidations); - - // Create and checkout gen2 branch before generate - const gen2BranchName = `gen2-${envName}`; - this.logger.info(`Creating and checking out branch '${gen2BranchName}'...`); - await execa('git', ['add', '.'], { cwd: appPath }); - await execa('git', ['commit', '--allow-empty', '-m', 'chore: before generate'], { cwd: appPath }); - await execa('git', ['checkout', '-b', gen2BranchName], { cwd: appPath }); - - // Generate Gen2 code - await this.generate(appPath, skipValidations); - - this.logger.info('Pre-deployment workflow completed'); - } - - /** - * Deploy Gen2 app using ampx sandbox. - * - * Runs `npx ampx sandbox --once` to do a single non-interactive deployment. - * Returns the Gen2 root stack name by querying CloudFormation. - * - * @param appPath - Path to the app directory - * @param deploymentName - Unique deployment name for stack identification - * @param branchName - Branch name to set as AWS_BRANCH env var for unique resource naming - */ - public async deployGen2Sandbox(appPath: string, deploymentName: string, branchName: string): Promise { - this.logger.info('Deploying Gen2 app using ampx sandbox...'); - this.logger.debug(`App path: ${appPath}`); - this.logger.debug(`Branch name (AWS_BRANCH): ${branchName}`); - - const startTime = Date.now(); - - // Set AWS_PROFILE and AWS_BRANCH env vars - // AWS_BRANCH ensures unique Lambda function names across sandbox deployments - const env: NodeJS.ProcessEnv = { - ...process.env, - AWS_BRANCH: branchName, - ...(this.profile && { AWS_PROFILE: this.profile }), - }; - - this.logger.info('Installing dependencies...'); - await execa('npm', ['install'], { cwd: appPath }); - const result = await execa('npx', ['ampx', 'sandbox', '--once'], { - cwd: appPath, - reject: false, - stdio: 'inherit', - env, - }); - - const durationMs = Date.now() - startTime; - - if (result.exitCode !== 0) { - throw new Error(`ampx sandbox failed`); - } - - this.logger.info(`ampx sandbox completed (${durationMs}ms)`); - - // Find the Gen2 root stack by querying CloudFormation - // Pattern: amplify---sandbox- - const username = os.userInfo().username; - const stackPrefix = `amplify-${deploymentName}-${username}-sandbox`; - - const gen2StackName = await this.findGen2RootStack(stackPrefix); - this.logger.info(`Gen2 stack name: ${gen2StackName}`); - - return gen2StackName; - } - - /** - * Find the Gen2 root stack by prefix using AWS CLI. - */ - private async findGen2RootStack(stackPrefix: string): Promise { - this.logger.debug(`Looking for stack with prefix: ${stackPrefix}`); - - const env = this.profile ? { ...process.env, AWS_PROFILE: this.profile } : undefined; - - const result = await execa( - 'aws', - [ - 'cloudformation', - 'list-stacks', - '--stack-status-filter', - 'CREATE_COMPLETE', - 'UPDATE_COMPLETE', - '--query', - `StackSummaries[?starts_with(StackName, '${stackPrefix}')].StackName`, - '--output', - 'text', - ], - { reject: false, env }, - ); - - if (result.exitCode !== 0) { - throw new Error(`Failed to list CloudFormation stacks: ${result.stderr || result.stdout}`); - } - - const stacks = result.stdout - .trim() - .split(/\s+/) - .filter((s) => s.length > 0); - - // Find root stacks (those without nested stack suffixes like -auth, -data, -storage) - const rootStacks = stacks.filter((name) => { - const suffix = name.replace(stackPrefix, ''); - // Root stack has pattern: - (10 char hex) - // Nested stacks have pattern: --- - return /^-[a-f0-9]+$/.test(suffix); - }); - - if (rootStacks.length === 0) { - throw new Error(`No Gen2 sandbox stack found with prefix: ${stackPrefix}`); - } - - // Return the most recently created (should only be one) - return rootStacks[0]; - } -}