Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

adds layers to function #1835

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 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
5 changes: 5 additions & 0 deletions .changeset/serious-dragons-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@aws-amplify/backend-function': minor
---

adds support to reference existing layers in defineFunction
1 change: 1 addition & 0 deletions .eslint_dictionary.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"argv",
"arn",
"arns",
"awslayer",
"backends",
"birthdate",
"bundler",
Expand Down
1 change: 1 addition & 0 deletions packages/backend-function/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type FunctionProps = {
environment?: Record<string, string | BackendSecret>;
runtime?: NodeVersion;
schedule?: FunctionSchedule | FunctionSchedule[];
layers?: Record<string, string>;
};

// @public (undocumented)
Expand Down
69 changes: 51 additions & 18 deletions packages/backend-function/src/factory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
import {
FunctionOutput,
functionOutputKey,
} from '@aws-amplify/backend-output-schemas';
import { AttributionMetadataStorage } from '@aws-amplify/backend-output-storage';
import { AmplifyUserError, TagName } from '@aws-amplify/platform-core';
import {
BackendOutputStorageStrategy,
BackendSecret,
Expand All @@ -12,28 +18,28 @@ import {
ResourceProvider,
SsmEnvironmentEntry,
} from '@aws-amplify/plugin-types';
import { Construct } from 'constructs';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
import * as path from 'path';
import { getCallerDirectory } from './get_caller_directory.js';
import { Duration, Stack, Tags } from 'aws-cdk-lib';
import { CfnFunction, Runtime } from 'aws-cdk-lib/aws-lambda';
import { createRequire } from 'module';
import { FunctionEnvironmentTranslator } from './function_env_translator.js';
import { Rule } from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import { Policy } from 'aws-cdk-lib/aws-iam';
import {
CfnFunction,
ILayerVersion,
LayerVersion,
Runtime,
} from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
import { readFileSync } from 'fs';
import { createRequire } from 'module';
import { fileURLToPath } from 'node:url';
import { EOL } from 'os';
import {
FunctionOutput,
functionOutputKey,
} from '@aws-amplify/backend-output-schemas';
import * as path from 'path';
import { FunctionEnvironmentTranslator } from './function_env_translator.js';
import { FunctionEnvironmentTypeGenerator } from './function_env_type_generator.js';
import { AttributionMetadataStorage } from '@aws-amplify/backend-output-storage';
import { fileURLToPath } from 'node:url';
import { AmplifyUserError, TagName } from '@aws-amplify/platform-core';
import { getCallerDirectory } from './get_caller_directory.js';
import { FunctionLayerArnParser } from './layer_parser.js';
import { convertFunctionSchedulesToRuleSchedules } from './schedule_parser.js';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import { Rule } from 'aws-cdk-lib/aws-events';

const functionStackType = 'function-Lambda';

Expand Down Expand Up @@ -121,6 +127,19 @@ export type FunctionProps = {
* schedule: "0 9 * * 2" // every Monday at 9am
*/
schedule?: FunctionSchedule | FunctionSchedule[];

/**
* Attach Lambda layers to a function
ykethan marked this conversation as resolved.
Show resolved Hide resolved
* - A Lambda layer is represented by an object of key/value pair where the key is the module name that is exported from your layer and the value is the ARN of the layer. The key (module name) is used to externalize the module dependency so it doesn't get bundled with your lambda function
* - Maximum of 5 layers can be attached to a function and must be in the same region as the function.
* @example
* layers: {
* "@aws-lambda-powertools/logger": "arn:aws:lambda:<current-region>:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:11"
* },
* @see [Amplify documentation for Lambda layers](https://docs.amplify.aws/react/build-a-backend/functions/add-lambda-layers)
* @see [AWS documentation for Lambda layers](https://docs.aws.amazon.com/lambda/latest/dg/chapter-layers.html)
*/
layers?: Record<string, string>;
};

/**
Expand Down Expand Up @@ -158,6 +177,8 @@ class FunctionFactory implements ConstructFactory<AmplifyFunction> {
): HydratedFunctionProps => {
const name = this.resolveName();
resourceNameValidator?.validate(name);
const parser = new FunctionLayerArnParser();
const layers = parser.parseLayers(this.props.layers ?? {}, name);
return {
name,
entry: this.resolveEntry(),
Expand All @@ -166,6 +187,7 @@ class FunctionFactory implements ConstructFactory<AmplifyFunction> {
environment: this.props.environment ?? {},
runtime: this.resolveRuntime(),
schedule: this.resolveSchedule(),
layers,
};
};

Expand Down Expand Up @@ -279,10 +301,19 @@ class FunctionGenerator implements ConstructContainerEntryGenerator {
scope,
backendSecretResolver,
}: GenerateContainerEntryProps) => {
// resolve layers to LayerVersion objects for the NodejsFunction constructor using the scope.
const resolvedLayers = Object.entries(this.props.layers).map(([key, arn]) =>
LayerVersion.fromLayerVersionArn(
scope,
`${this.props.name}-${key}-layer`,
arn
)
);

return new AmplifyFunction(
scope,
this.props.name,
this.props,
{ ...this.props, resolvedLayers },
backendSecretResolver,
this.outputStorageStrategy
);
Expand All @@ -301,7 +332,7 @@ class AmplifyFunction
constructor(
scope: Construct,
id: string,
props: HydratedFunctionProps,
props: HydratedFunctionProps & { resolvedLayers: ILayerVersion[] },
backendSecretResolver: BackendSecretResolver,
outputStorageStrategy: BackendOutputStorageStrategy<FunctionOutput>
) {
Expand Down Expand Up @@ -349,6 +380,7 @@ class AmplifyFunction
timeout: Duration.seconds(props.timeoutSeconds),
memorySize: props.memoryMB,
runtime: nodeVersionMap[props.runtime],
layers: props.resolvedLayers,
bundling: {
format: OutputFormat.ESM,
banner: bannerCode,
Expand All @@ -359,6 +391,7 @@ class AmplifyFunction
},
minify: true,
sourceMap: true,
externalModules: Object.keys(props.layers),
},
});
} catch (error) {
Expand Down
177 changes: 177 additions & 0 deletions packages/backend-function/src/layer_parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { StackMetadataBackendOutputStorageStrategy } from '@aws-amplify/backend-output-storage';
import {
ConstructContainerStub,
ResourceNameValidatorStub,
StackResolverStub,
} from '@aws-amplify/backend-platform-test-stubs';
import { AmplifyUserError } from '@aws-amplify/platform-core';
import {
ConstructFactoryGetInstanceProps,
ResourceNameValidator,
} from '@aws-amplify/plugin-types';
import { App, Stack } from 'aws-cdk-lib';
import { Template } from 'aws-cdk-lib/assertions';
import assert from 'node:assert';
import { beforeEach, describe, it } from 'node:test';
import { defineFunction } from './factory.js';

const createStackAndSetContext = (): Stack => {
const app = new App();
app.node.setContext('amplify-backend-name', 'testEnvName');
app.node.setContext('amplify-backend-namespace', 'testBackendId');
app.node.setContext('amplify-backend-type', 'branch');
const stack = new Stack(app);
return stack;
};

void describe('AmplifyFunctionFactory - Layers', () => {
let rootStack: Stack;
let getInstanceProps: ConstructFactoryGetInstanceProps;
let resourceNameValidator: ResourceNameValidator;

beforeEach(() => {
rootStack = createStackAndSetContext();

const constructContainer = new ConstructContainerStub(
new StackResolverStub(rootStack)
);

const outputStorageStrategy = new StackMetadataBackendOutputStorageStrategy(
rootStack
);

resourceNameValidator = new ResourceNameValidatorStub();

getInstanceProps = {
constructContainer,
outputStorageStrategy,
resourceNameValidator,
};
});

void it('sets a valid layer', () => {
const layerArn = 'arn:aws:lambda:us-east-1:123456789012:layer:my-layer:1';
const functionFactory = defineFunction({
entry: './test-assets/default-lambda/handler.ts',
name: 'lambdaWithLayer',
layers: {
myLayer: layerArn,
},
});
const lambda = functionFactory.getInstance(getInstanceProps);
const template = Template.fromStack(Stack.of(lambda.resources.lambda));

template.resourceCountIs('AWS::Lambda::Function', 1);
template.hasResourceProperties('AWS::Lambda::Function', {
Handler: 'index.handler',
Layers: [layerArn],
});
});

void it('sets multiple valid layers', () => {
const layerArns = [
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-1:1',
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-2:1',
];
const functionFactory = defineFunction({
entry: './test-assets/default-lambda/handler.ts',
name: 'lambdaWithMultipleLayers',
layers: {
myLayer1: layerArns[0],
myLayer2: layerArns[1],
},
});
const lambda = functionFactory.getInstance(getInstanceProps);
const template = Template.fromStack(Stack.of(lambda.resources.lambda));

template.resourceCountIs('AWS::Lambda::Function', 1);
template.hasResourceProperties('AWS::Lambda::Function', {
Handler: 'index.handler',
Layers: layerArns,
});
});

void it('throws an error for an invalid layer ARN', () => {
const invalidLayerArn = 'invalid:arn';
const functionFactory = defineFunction({
entry: './test-assets/default-lambda/handler.ts',
name: 'lambdaWithInvalidLayer',
layers: {
invalidLayer: invalidLayerArn,
},
});
assert.throws(
() => functionFactory.getInstance(getInstanceProps),
(error: AmplifyUserError) => {
assert.strictEqual(
error.message,
`Invalid ARN format for layer: ${invalidLayerArn}`
);
assert.ok(error.resolution);
return true;
}
);
});

void it('throws an error for exceeding the maximum number of layers', () => {
const layerArns = [
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-1:1',
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-2:1',
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-3:1',
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-4:1',
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-5:1',
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer-6:1',
];
const layers: Record<string, string> = layerArns.reduce(
(acc, arn, index) => {
acc[`layer${index + 1}`] = arn;
return acc;
},
{} as Record<string, string>
);

const functionFactory = defineFunction({
entry: './test-assets/default-lambda/handler.ts',
name: 'lambdaWithTooManyLayers',
layers,
});

assert.throws(
() => functionFactory.getInstance(getInstanceProps),
(error: AmplifyUserError) => {
assert.strictEqual(
error.message,
`A maximum of 5 unique layers can be attached to a function.`
);
assert.ok(error.resolution);
return true;
}
);
});

void it('checks if only unique Arns are being used', () => {
const duplicateArn =
'arn:aws:lambda:us-east-1:123456789012:layer:my-layer:1';
const functionFactory = defineFunction({
entry: './test-assets/default-lambda/handler.ts',
name: 'lambdaWithDuplicateLayers',
layers: {
layer1: duplicateArn,
layer2: duplicateArn,
layer3: duplicateArn,
layer4: duplicateArn,
layer5: duplicateArn,
layer6: duplicateArn,
},
});

const lambda = functionFactory.getInstance(getInstanceProps);
const template = Template.fromStack(Stack.of(lambda.resources.lambda));

template.resourceCountIs('AWS::Lambda::Function', 1);
template.hasResourceProperties('AWS::Lambda::Function', {
Handler: 'index.handler',
Layers: [duplicateArn],
});
});
});
Loading