diff --git a/GEN2_MIGRATION_GUIDE.md b/GEN2_MIGRATION_GUIDE.md index 49e78049e82..d3f3c313f1f 100644 --- a/GEN2_MIGRATION_GUIDE.md +++ b/GEN2_MIGRATION_GUIDE.md @@ -1051,7 +1051,7 @@ by the CLI setting that configures them. - 🔴 **function** - ⚠️ **Do you want to invoke this function on a recurring schedule** - - 🔴 **Do you want to enable Lambda layers for this function** + - 🟡 **Do you want to enable Lambda layers for this function** (_generate_ ✔ _refactor_ ✗) - 🟢 **Do you want to configure environment variables for this function** - 🟡 **Do you want to configure secret values this function can access** (_generate_ ✗ _refactor_ ✔) - ➤ **Choose the package manager that you want to use** diff --git a/amplify-migration-apps/project-boards/_snapshot.post.generate/amplify/function/quotegenerator/resource.ts b/amplify-migration-apps/project-boards/_snapshot.post.generate/amplify/function/quotegenerator/resource.ts index 1ab710bad93..0214f3a95a9 100644 --- a/amplify-migration-apps/project-boards/_snapshot.post.generate/amplify/function/quotegenerator/resource.ts +++ b/amplify-migration-apps/project-boards/_snapshot.post.generate/amplify/function/quotegenerator/resource.ts @@ -8,5 +8,9 @@ export const quotegenerator = defineFunction({ timeoutSeconds: 25, memoryMB: 128, environment: { ENV: `${branchName}`, REGION: 'us-east-1' }, + layers: { + SharedUtils: 'arn:aws:lambda:us-east-1:123456789012:layer:SharedUtils:3', + CommonDeps: 'arn:aws:lambda:us-east-1:123456789012:layer:CommonDeps:1', + }, runtime: 22, }); diff --git a/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/#current-cloud-backend/function/quotegenerator/quotegenerator-cloudformation-template.json b/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/#current-cloud-backend/function/quotegenerator/quotegenerator-cloudformation-template.json index 514feaf36cc..58fa18e793a 100644 --- a/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/#current-cloud-backend/function/quotegenerator/quotegenerator-cloudformation-template.json +++ b/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/#current-cloud-backend/function/quotegenerator/quotegenerator-cloudformation-template.json @@ -79,7 +79,10 @@ ] }, "Runtime": "nodejs22.x", - "Layers": [], + "Layers": [ + "arn:aws:lambda:us-east-1:123456789012:layer:SharedUtils:3", + "arn:aws:lambda:us-east-1:123456789012:layer:CommonDeps:1" + ], "Timeout": 25 } }, diff --git a/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/backend/function/quotegenerator/quotegenerator-cloudformation-template.json b/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/backend/function/quotegenerator/quotegenerator-cloudformation-template.json index 514feaf36cc..58fa18e793a 100644 --- a/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/backend/function/quotegenerator/quotegenerator-cloudformation-template.json +++ b/amplify-migration-apps/project-boards/_snapshot.pre.generate/amplify/backend/function/quotegenerator/quotegenerator-cloudformation-template.json @@ -79,7 +79,10 @@ ] }, "Runtime": "nodejs22.x", - "Layers": [], + "Layers": [ + "arn:aws:lambda:us-east-1:123456789012:layer:SharedUtils:3", + "arn:aws:lambda:us-east-1:123456789012:layer:CommonDeps:1" + ], "Timeout": 25 } }, diff --git a/amplify-migration-apps/project-boards/_snapshot.pre.refactor/amplify-projectboards-main-02940-functionquotegenerator-M4FF4RKY2IWJ.template.json b/amplify-migration-apps/project-boards/_snapshot.pre.refactor/amplify-projectboards-main-02940-functionquotegenerator-M4FF4RKY2IWJ.template.json index 514feaf36cc..58fa18e793a 100644 --- a/amplify-migration-apps/project-boards/_snapshot.pre.refactor/amplify-projectboards-main-02940-functionquotegenerator-M4FF4RKY2IWJ.template.json +++ b/amplify-migration-apps/project-boards/_snapshot.pre.refactor/amplify-projectboards-main-02940-functionquotegenerator-M4FF4RKY2IWJ.template.json @@ -79,7 +79,10 @@ ] }, "Runtime": "nodejs22.x", - "Layers": [], + "Layers": [ + "arn:aws:lambda:us-east-1:123456789012:layer:SharedUtils:3", + "arn:aws:lambda:us-east-1:123456789012:layer:CommonDeps:1" + ], "Timeout": 25 } }, diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/lambda.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/lambda.ts index aa8bb5c0702..26e232b330d 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/lambda.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/_framework/clients/lambda.ts @@ -99,6 +99,9 @@ export class LambdaMock { throw new Error(`Unexpected environment variable value for '${key}' in function '${resourceName}': ${value}`); } + const cfnLayers = template.Resources.LambdaFunction.Properties.Layers ?? []; + const layers = cfnLayers.map((arn: string) => ({ Arn: arn, CodeSize: 0 })); + return { Configuration: { FunctionName: input.FunctionName, @@ -108,6 +111,7 @@ export class LambdaMock { Environment: { Variables: envVariables, }, + Layers: layers, }, $metadata: {}, }; diff --git a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts index 21701ad68d4..dc4cba7ea6b 100644 --- a/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts +++ b/packages/amplify-cli/src/__tests__/commands/gen2-migration/generate/amplify/function/function.generator.test.ts @@ -1,5 +1,5 @@ import ts from 'typescript'; -import { FunctionGenerator } from '../../../../../../commands/gen2-migration/generate/amplify/function/function.generator'; +import { FunctionGenerator, extractLayers } from '../../../../../../commands/gen2-migration/generate/amplify/function/function.generator'; import { BackendGenerator } from '../../../../../../commands/gen2-migration/generate/amplify/backend.generator'; import { RootPackageJsonGenerator } from '../../../../../../commands/gen2-migration/generate/package.json.generator'; import { Gen1App } from '../../../../../../commands/gen2-migration/generate/_infra/gen1-app'; @@ -191,3 +191,66 @@ describe('FunctionGenerator', () => { }); }); }); + +describe('extractLayers', () => { + it('returns undefined for undefined input', () => { + expect(extractLayers(undefined)).toBeUndefined(); + }); + + it('returns undefined for an empty array', () => { + expect(extractLayers([])).toBeUndefined(); + }); + + it('skips layers with missing Arn and returns undefined when no valid layers remain', () => { + expect(extractLayers([{ CodeSize: 100 }])).toBeUndefined(); + }); + + it('skips layers with missing Arn while still extracting valid ones', () => { + const result = extractLayers([{ CodeSize: 100 }, { Arn: 'arn:aws:lambda:us-east-1:123456789012:layer:ValidLayer:1', CodeSize: 200 }]); + expect(result).toEqual({ ValidLayer: 'arn:aws:lambda:us-east-1:123456789012:layer:ValidLayer:1' }); + }); + + it('throws on malformed ARN with too few segments', () => { + expect(() => extractLayers([{ Arn: 'arn:aws:lambda:us-east-1:123456789012:layer', CodeSize: 0 }])).toThrow( + 'Malformed Lambda layer ARN (expected at least 8 colon-delimited segments)', + ); + }); + + it('throws when ARN resource type is not "layer"', () => { + expect(() => extractLayers([{ Arn: 'arn:aws:lambda:us-east-1:123456789012:function:myFunc:1', CodeSize: 0 }])).toThrow( + "Expected Lambda layer ARN but got resource type 'function'", + ); + }); + + it('throws on duplicate layer names with a descriptive message', () => { + expect(() => + extractLayers([ + { Arn: 'arn:aws:lambda:us-east-1:111111111111:layer:SharedUtils:1', CodeSize: 0 }, + { Arn: 'arn:aws:lambda:us-east-1:222222222222:layer:SharedUtils:2', CodeSize: 0 }, + ]), + ).toThrow("Duplicate layer name 'SharedUtils' detected"); + }); + + it('extracts up to 5 layers (AWS maximum)', () => { + const layers = Array.from({ length: 5 }, (_, i) => ({ + Arn: `arn:aws:lambda:us-east-1:123456789012:layer:Layer${i}:1`, + CodeSize: i * 100, + })); + const result = extractLayers(layers); + expect(Object.keys(result!)).toHaveLength(5); + for (let i = 0; i < 5; i++) { + expect(result![`Layer${i}`]).toBe(`arn:aws:lambda:us-east-1:123456789012:layer:Layer${i}:1`); + } + }); + + it('extracts layer name and full ARN in the happy path', () => { + const result = extractLayers([ + { Arn: 'arn:aws:lambda:us-east-1:123456789012:layer:SharedUtils:3', CodeSize: 1024 }, + { Arn: 'arn:aws:lambda:us-east-1:123456789012:layer:CommonDeps:1', CodeSize: 2048 }, + ]); + expect(result).toEqual({ + SharedUtils: 'arn:aws:lambda:us-east-1:123456789012:layer:SharedUtils:3', + CommonDeps: 'arn:aws:lambda:us-east-1:123456789012:layer:CommonDeps:1', + }); + }); +}); diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts index 3bff5471815..bf6cd919d66 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.generator.ts @@ -39,6 +39,12 @@ interface ResolvedFunction { readonly runtime?: string; readonly schedule?: string; readonly environment?: Readonly>; + /** + * Lambda layer ARNs keyed by the layer name extracted from the ARN. + * Undefined when the function has no attached layers. + */ + readonly layers?: Readonly>; + readonly escapeHatches: readonly EnvVarEscapeHatch[]; readonly dynamoActions: readonly string[]; readonly kinesisActions: readonly string[]; @@ -175,6 +181,8 @@ export class FunctionGenerator implements Planner { // Extract DynamoDB/Kinesis actions and GraphQL API permissions from the function's CloudFormation template const { dynamoActions, kinesisActions, graphqlApiPermissions, authAccess } = this.extractCfnPermissions(); + const layers = extractLayers(config.Layers); + return { resourceName: this.resource.resourceName, category: this.category, @@ -185,6 +193,7 @@ export class FunctionGenerator implements Planner { runtime, schedule, environment: Object.keys(retained).length > 0 ? retained : undefined, + layers, escapeHatches, dynamoActions, kinesisActions, @@ -208,6 +217,7 @@ export class FunctionGenerator implements Planner { runtime: func.runtime, schedule: func.schedule, environment: func.environment, + layers: func.layers, }; const nodes = this.renderer.render(renderOpts); @@ -862,6 +872,56 @@ function classifyEnvVars(variables: Record): { return { retained, escapeHatches }; } +/** + * Extracts Lambda layer ARNs into a `Record` for use in + * `defineFunction({ layers })`. + * + * The key is the layer name parsed from the ARN (e.g. `SharedUtils`), and + * the value is the full layer version ARN. Layers with missing ARNs are + * skipped. + * + * @param sdkLayers - The `Layers` array from `FunctionConfiguration`. + * @returns A layers record, or `undefined` if no valid layers exist. + * @throws Error if an ARN is malformed, not a layer ARN, or if two layers + * share the same name. + */ +export function extractLayers( + sdkLayers: ReadonlyArray<{ Arn?: string; CodeSize?: number }> | undefined, +): Readonly> | undefined { + if (!sdkLayers || sdkLayers.length === 0) return undefined; + + const result: Record = {}; + for (const layer of sdkLayers) { + if (!layer.Arn) continue; + + // ARN format: arn:aws:lambda:::layer:: + const parts = layer.Arn.split(':'); + if (parts.length < 8) { + throw new Error(`Malformed Lambda layer ARN (expected at least 8 colon-delimited segments): ${layer.Arn}`); + } + if (parts[5] !== 'layer') { + throw new Error(`Expected Lambda layer ARN but got resource type '${parts[5]}': ${layer.Arn}`); + } + + const layerName = parts[6]; + if (!layerName) { + throw new Error(`Lambda layer ARN missing layer name: ${layer.Arn}`); + } + + if (result[layerName]) { + throw new Error( + `Duplicate layer name '${layerName}' detected. ` + + `Existing: ${result[layerName]}, New: ${layer.Arn}. ` + + `Manual resolution required — rename one layer key in the Gen2 defineFunction() output.`, + ); + } + + result[layerName] = layer.Arn; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + /** * Creates `backend.functionName.addEnvironment(name, expression)`. */ diff --git a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts index 57eb2444fdf..327e8644dc2 100644 --- a/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts +++ b/packages/amplify-cli/src/commands/gen2-migration/generate/amplify/function/function.renderer.ts @@ -15,6 +15,11 @@ export interface RenderDefineFunctionOptions { readonly runtime?: string; readonly schedule?: string; readonly environment?: Readonly>; + /** + * Lambda layer ARNs keyed by the layer name extracted from the ARN. + * Undefined when the function has no attached layers. + */ + readonly layers?: Readonly>; } /** @@ -69,6 +74,9 @@ export class FunctionRenderer { // environment this.renderEnvironment(properties, namedImports, opts); + // layers + this.renderLayers(properties, opts.layers); + // runtime this.renderRuntime(properties, opts.runtime); @@ -135,6 +143,16 @@ export class FunctionRenderer { target.push(factory.createPropertyAssignment('schedule', factory.createStringLiteral(converted))); } } + + private renderLayers(target: ObjectLiteralElementLike[], layers?: Readonly>): void { + if (!layers || Object.keys(layers).length === 0) return; + + const layerProps = Object.entries(layers).map(([key, value]) => + factory.createPropertyAssignment(factory.createStringLiteral(key), factory.createStringLiteral(value)), + ); + + target.push(factory.createPropertyAssignment('layers', factory.createObjectLiteralExpression(layerProps, true))); + } } /**