Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GEN2_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -108,6 +111,7 @@ export class LambdaMock {
Environment: {
Variables: envVariables,
},
Layers: layers,
},
$metadata: {},
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ interface ResolvedFunction {
readonly runtime?: string;
readonly schedule?: string;
readonly environment?: Readonly<Record<string, string>>;
/**
* Lambda layer ARNs keyed by the layer name extracted from the ARN.
* Undefined when the function has no attached layers.
*/
readonly layers?: Readonly<Record<string, string>>;

readonly escapeHatches: readonly EnvVarEscapeHatch[];
readonly dynamoActions: readonly string[];
readonly kinesisActions: readonly string[];
Expand Down Expand Up @@ -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,
Expand All @@ -185,6 +193,7 @@ export class FunctionGenerator implements Planner {
runtime,
schedule,
environment: Object.keys(retained).length > 0 ? retained : undefined,
layers,
escapeHatches,
dynamoActions,
kinesisActions,
Expand All @@ -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);
Expand Down Expand Up @@ -862,6 +872,56 @@ function classifyEnvVars(variables: Record<string, string>): {
return { retained, escapeHatches };
}

/**
* Extracts Lambda layer ARNs into a `Record<string, string>` 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<Record<string, string>> | undefined {
if (!sdkLayers || sdkLayers.length === 0) return undefined;

const result: Record<string, string> = {};
for (const layer of sdkLayers) {
if (!layer.Arn) continue;

// ARN format: arn:aws:lambda:<region>:<account>:layer:<layerName>:<version>
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)`.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ export interface RenderDefineFunctionOptions {
readonly runtime?: string;
readonly schedule?: string;
readonly environment?: Readonly<Record<string, string>>;
/**
* Lambda layer ARNs keyed by the layer name extracted from the ARN.
* Undefined when the function has no attached layers.
*/
readonly layers?: Readonly<Record<string, string>>;
}

/**
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -135,6 +143,16 @@ export class FunctionRenderer {
target.push(factory.createPropertyAssignment('schedule', factory.createStringLiteral(converted)));
}
}

private renderLayers(target: ObjectLiteralElementLike[], layers?: Readonly<Record<string, string>>): 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)));
}
}

/**
Expand Down
Loading