diff --git a/amplify-migration-apps/product-catalog/configure.sh b/amplify-migration-apps/product-catalog/configure.sh deleted file mode 100755 index b4f6510f709..00000000000 --- a/amplify-migration-apps/product-catalog/configure.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -set -euxo pipefail - -s3_trigger_function_name=$(ls amplify/backend/function | grep S3Trigger) - -cp -f schema.graphql ./amplify/backend/api/productcatalog/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 diff --git a/amplify-migration-apps/product-catalog/custom-roles.json b/amplify-migration-apps/product-catalog/custom-roles.json deleted file mode 100644 index 0454e98f10a..00000000000 --- a/amplify-migration-apps/product-catalog/custom-roles.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "adminRoleNames": [ - "amplify-${appId}" - ] -} \ No newline at end of file diff --git a/amplify-migration-apps/product-catalog/lowstockproducts.js b/amplify-migration-apps/product-catalog/lowstockproducts.js index 23653856e33..90f6481989b 100644 --- a/amplify-migration-apps/product-catalog/lowstockproducts.js +++ b/amplify-migration-apps/product-catalog/lowstockproducts.js @@ -6,10 +6,32 @@ const { SSMClient, GetParametersCommand } = require('@aws-sdk/client-ssm'); const Sha256 = crypto.Sha256; -const GRAPHQL_ENDPOINT = process.env.API_PRODUCTCATALOG_GRAPHQLAPIENDPOINTOUTPUT; const AWS_REGION = process.env.AWS_REGION || 'us-east-1'; const LOW_STOCK_THRESHOLD = parseInt(process.env.LOW_STOCK_THRESHOLD) || 5; +// Resolve the GraphQL endpoint from (in order): +// 1. Environment variable (works when Amplify CLI wires up API access) +// 2. The invoking AppSync event's host header (works for @function invocations) +function getGraphQLEndpoint(event) { + // Try the standard env var name + const fromEnv = process.env.API_PRODUCTCATALOG_GRAPHQLAPIENDPOINTOUTPUT + || Object.entries(process.env).find(([k]) => k.startsWith('API_') && k.endsWith('_GRAPHQLAPIENDPOINTOUTPUT'))?.[1]; + + if (fromEnv && fromEnv.startsWith('http')) return fromEnv; + + // Extract from the invoking AppSync API's host header. + // When AppSync invokes a Lambda via @function, the event contains the + // request headers from the original GraphQL call, including the host. + const host = event?.request?.headers?.host; + if (host && host.includes('appsync-api')) { + const endpoint = `https://${host}/graphql`; + console.log(`Using endpoint from event host header: ${endpoint}`); + return endpoint; + } + + throw new Error('Could not determine GraphQL endpoint from env vars or event'); +} + const listProductsQuery = ` query ListProducts { listProducts { @@ -29,7 +51,7 @@ exports.handler = async (event) => { try { const secretValue = await fetchSecret(); - const products = await fetchProducts(); + const products = await fetchProducts(event); const lowStockProducts = products.filter((product) => product.stock !== null && product.stock < LOW_STOCK_THRESHOLD); console.log(`Found ${lowStockProducts.length} low stock products`); @@ -48,8 +70,9 @@ exports.handler = async (event) => { } }; -async function fetchProducts() { - const endpoint = new URL(GRAPHQL_ENDPOINT); +async function fetchProducts(event) { + const graphqlEndpoint = getGraphQLEndpoint(event); + const endpoint = new URL(graphqlEndpoint); const signer = new SignatureV4({ credentials: defaultProvider(), @@ -70,7 +93,7 @@ async function fetchProducts() { }); const signed = await signer.sign(requestToBeSigned); - const request = new Request(GRAPHQL_ENDPOINT, signed); + const request = new Request(graphqlEndpoint, signed); const response = await fetch(request); const status = response.status; diff --git a/amplify-migration-apps/product-catalog/onimageuploaded.js b/amplify-migration-apps/product-catalog/onimageuploaded.js index 3f1c409afb5..5ae80ca5c7d 100644 --- a/amplify-migration-apps/product-catalog/onimageuploaded.js +++ b/amplify-migration-apps/product-catalog/onimageuploaded.js @@ -13,7 +13,8 @@ const { HttpRequest } = require('@aws-sdk/protocol-http'); const Sha256 = crypto.Sha256; -const GRAPHQL_ENDPOINT = process.env.API_PRODUCTCATALOG_GRAPHQLAPIENDPOINTOUTPUT; +const GRAPHQL_ENDPOINT = process.env.API_PRODUCTCATALOG_GRAPHQLAPIENDPOINTOUTPUT + || Object.entries(process.env).find(([k]) => k.startsWith('API_') && k.endsWith('_GRAPHQLAPIENDPOINTOUTPUT'))?.[1]; const AWS_REGION = process.env.AWS_REGION || 'us-east-1'; function updateProductImageUploadedAtMutation(productId, date) { 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..d1a70c7fed9 --- /dev/null +++ b/amplify-migration-apps/product-catalog/post-generate.ts @@ -0,0 +1,284 @@ +#!/usr/bin/env npx ts-node +/** + * Post-generate script for product-catalog app. + * + * Applies manual edits required after `amplify gen2-migration generate`: + * 1. Update branchName in amplify/data/resource.ts to "sandbox" + * 2. Add aws_iam import and appsync:GraphQL policy to amplify/backend.ts + * 3. Fix missing awsRegion in GraphQL API userPoolConfig + * 4. Convert lowstockproducts function from CommonJS to ESM and use secret() + * 5. 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 updateBranchName(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'data', 'resource.ts'); + console.log(`Updating branchName in ${resourcePath}...`); + + const content = await fs.readFile(resourcePath, 'utf-8'); + const updated = content.replace( + /branchName:\s*['"]([^'"]+)['"]/, + `branchName: 'sandbox'`, + ); + + if (updated === content) { + console.log(' No branchName found to update, skipping'); + return; + } + + await fs.writeFile(resourcePath, updated, 'utf-8'); + console.log(' Updated branchName to "sandbox"'); +} + +/** + * Read the Gen1 AppSync API ID from amplify-meta.json. + * The API name is dynamic (based on deployment name), so we scan for the first API entry. + */ +async function getGen1ApiId(appPath: string): Promise { + const metaPath = path.join(appPath, 'amplify', 'backend', 'amplify-meta.json'); + try { + const meta = JSON.parse(await fs.readFile(metaPath, 'utf-8')) as Record>>; + const apis = meta.api ?? {}; + for (const apiConfig of Object.values(apis)) { + const output = apiConfig.output as Record | undefined; + if (output?.GraphQLAPIIdOutput) { + return output.GraphQLAPIIdOutput; + } + } + } catch { + // amplify-meta.json may not exist yet + } + return undefined; +} + +/** + * Add aws_iam import and appsync:GraphQL policy for the Gen1 API to backend.ts. + * + * The Gen2 auth role needs permission to call the Gen1 AppSync API during the + * transition period when both Gen1 and Gen2 stacks coexist. + */ +async function addAppsyncPolicyToBackend(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + console.log(`Adding appsync:GraphQL policy to ${backendPath}...`); + + let content: string; + try { + content = await fs.readFile(backendPath, 'utf-8'); + } catch { + console.log(' backend.ts not found, skipping'); + return; + } + + // Add aws_iam to the cdk import + if (!content.includes('aws_iam')) { + content = content.replace( + /import\s*\{([^}]*)\}\s*from\s*["']aws-cdk-lib["']/, + (match, imports: string) => { + const trimmed = imports.trim().replace(/,\s*$/, ''); + return `import { ${trimmed}, aws_iam } from "aws-cdk-lib"`; + }, + ); + } + + // Get the Gen1 API ID to scope the policy + const apiId = await getGen1ApiId(appPath); + const resourceArn = apiId + ? `\`arn:aws:appsync:\${backend.data.stack.region}:\${backend.data.stack.account}:apis/${apiId}/*\`` + : '`arn:aws:appsync:${backend.data.stack.region}:${backend.data.stack.account}:apis/*`'; + + // Add the policy statement before the last line (export or closing) + // Look for a good insertion point — after defineBackend call or before export + const policyBlock = ` +backend.auth.resources.authenticatedUserIamRole.addToPrincipalPolicy(new aws_iam.PolicyStatement({ + effect: aws_iam.Effect.ALLOW, + actions: ['appsync:GraphQL'], + resources: [${resourceArn}], +})); +`; + + if (content.includes('addToPrincipalPolicy')) { + console.log(' appsync:GraphQL policy already present, skipping'); + return; + } + + // Insert before the last line + const lines = content.split('\n'); + lines.splice(lines.length - 1, 0, policyBlock); + content = lines.join('\n'); + + await fs.writeFile(backendPath, content, 'utf-8'); + console.log(` Added appsync:GraphQL policy${apiId ? ` for API ${apiId}` : ' (wildcard)'}`); +} + +/** + * Convert lowstockproducts Lambda from CommonJS to ESM and switch to secret(). + * + * - exports.handler → export async function handler + * - Replace SSM secret fetch with process.env['PRODUCT_CATALOG_SECRET'] + */ +async function convertLowstockproductsToESM(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 CommonJS exports to ESM + let updated = content.replace( + /exports\.handler\s*=\s*async\s*\((\w*)\)\s*=>\s*\{/g, + 'export async function handler($1) {', + ); + + // Replace SSM secret fetch with direct env var access + updated = updated.replace( + /const secretValue = await fetchSecret\(\);/, + "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 switched to env var secret'); +} + +/** + * Update lowstockproducts resource.ts to use secret() instead of hardcoded SSM path. + */ +async function updateLowstockproductsResource(appPath: string): Promise { + const resourcePath = path.join(appPath, 'amplify', 'function', 'lowstockproducts', 'resource.ts'); + console.log(`Updating lowstockproducts resource.ts...`); + + let content: string; + try { + content = await fs.readFile(resourcePath, 'utf-8'); + } catch { + console.log(' resource.ts not found, skipping'); + return; + } + + // Add secret import + let updated = content.replace( + /import\s*\{\s*defineFunction\s*\}\s*from\s*["']@aws-amplify\/backend["']/, + 'import { defineFunction, secret } from "@aws-amplify/backend"', + ); + + // Replace hardcoded SSM path with secret() + updated = updated.replace( + /PRODUCT_CATALOG_SECRET:\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()'); +} + + +/** + * Fix missing awsRegion in GraphQL API userPoolConfig. + * + * The generated backend.ts sets additionalAuthenticationProviders with + * userPoolConfig but omits awsRegion, causing AppSync to reject the config. + */ +async function fixUserPoolRegionInGraphqlApi(appPath: string): Promise { + const backendPath = path.join(appPath, 'amplify', 'backend.ts'); + console.log(`Fixing user pool region in GraphQL API config in ${backendPath}...`); + + let content: string; + try { + content = await fs.readFile(backendPath, 'utf-8'); + } catch { + console.log(' backend.ts not found, skipping'); + return; + } + + const updated = content.replace( + /userPoolConfig:\s*\{\s*userPoolId:\s*backend\.auth\.resources\.userPool\.userPoolId,?\s*\}/g, + `userPoolConfig: { + userPoolId: backend.auth.resources.userPool.userPoolId, + awsRegion: backend.auth.stack.region, + }`, + ); + + if (updated === content) { + console.log(' No userPoolConfig found to fix, skipping'); + return; + } + + await fs.writeFile(backendPath, updated, 'utf-8'); + console.log(' Added awsRegion to userPoolConfig'); +} + +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; + } + + 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 updateBranchName(appPath); + await addAppsyncPolicyToBackend(appPath); + await fixUserPoolRegionInGraphqlApi(appPath); + await convertLowstockproductsToESM(appPath); + await updateLowstockproductsResource(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..ed2980e89a6 --- /dev/null +++ b/amplify-migration-apps/product-catalog/post-refactor.ts @@ -0,0 +1,73 @@ +#!/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 sync with deployed template + */ + +import fs from 'fs/promises'; +import path from 'path'; + +interface PostRefactorOptions { + appPath: string; + envName?: string; +} + +/** + * Uncomment the s3Bucket.bucketName line in backend.ts. + * + * The generate step produces a commented line like: + * // s3Bucket.bucketName = 'bucket-name-here'; + * + * After refactor, we need to uncomment it to sync with the deployed template. + */ +async function uncommentS3BucketName(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; + } + + const updated = content.replace( + /\/\/\s*(s3Bucket\.bucketName\s*=\s*['"][^'"]+['"];?)/g, + '$1', + ); + + if (updated === content) { + console.log(' No commented s3Bucket.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 uncommentS3BucketName(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/core/category-initializer.ts b/packages/amplify-gen2-migration-e2e-system/src/core/category-initializer.ts index 67b895da3ff..40374dd7e3c 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 @@ -204,10 +204,22 @@ export class CategoryInitializer { // Use addApi with explicit auth types config. // Pass requireAuthSetup = false because the auth category is already initialized, // so the CLI won't prompt for Cognito setup — it reuses the existing user pool. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const authTypesConfig: Record> = { 'API key': {} }; - if (needsCognitoAuth) authTypesConfig['Amazon Cognito User Pool'] = {}; - if (needsIamAuth) authTypesConfig['IAM'] = {}; + // Build authTypesConfig in the order specified by migration-config.json so the + // first auth mode becomes the default (addApi uses the first key as default). + const authModeMap: Record = { + IAM: 'IAM', + API_KEY: 'API key', + COGNITO_USER_POOLS: 'Amazon Cognito User Pool', + }; + const authTypesConfig: Record> = {}; + for (const mode of apiConfig.authModes ?? []) { + const mapped = authModeMap[mode]; + if (mapped) authTypesConfig[mapped] = {}; + } + // Fallback: ensure at least API key is present + if (Object.keys(authTypesConfig).length === 0) { + authTypesConfig['API key'] = {}; + } await addApi(appPath, authTypesConfig, false); } else { await addApiWithBlankSchema(appPath); 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..f65cbe9e448 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. */ + /** Run gen2-migration lock, skipping drift validations for known S3 trigger issues. */ public async lock(appPath: string): Promise { - await this.executeStep('lock', appPath); + await this.executeStep('lock', appPath, ['--skip-validations']); } /**