From 3f521c32ba5312c71c1568a6997a236c79747f21 Mon Sep 17 00:00:00 2001 From: Burak Karahan Date: Wed, 8 Jan 2025 00:06:33 +0300 Subject: [PATCH] Feature: custom runtimes to define function (#1602) * feature(function): add custom provided function support to defineFunction * chore: add changeset * chore(function): fix e2ee path and changeset * chore(function): add new API.md * fix(function): fix e2ee tests for provided function Co-authored-by: Kamil Sobol * chore: change changeset Co-authored-by: Kamil Sobol * chore(function): add without docker error message Co-authored-by: Kamil Sobol * chore(function): add provided function error message Co-authored-by: Kamil Sobol * chore(function): add missing imports * chore(function): fix lint error * Update packages/backend-function/src/provided_function_factory.ts Co-authored-by: Amplifiyer <51211245+Amplifiyer@users.noreply.github.com> * Update packages/backend-function/src/provided_function_factory.ts Co-authored-by: Amplifiyer <51211245+Amplifiyer@users.noreply.github.com> * chore(function): fix lint issue --------- Co-authored-by: Kamil Sobol Co-authored-by: Amplifiyer <51211245+Amplifiyer@users.noreply.github.com> --- .changeset/long-berries-greet.md | 6 + packages/backend-function/API.md | 14 +- packages/backend-function/src/factory.test.ts | 32 ++++ packages/backend-function/src/factory.ts | 110 ++++++-------- .../src/function_construct_base.ts | 58 +++++++ packages/backend-function/src/index.ts | 2 + .../src/provided_function_factory.ts | 142 ++++++++++++++++++ .../src/resource_access_acceptor.ts | 43 ++++++ .../amplify/func-src/handler_provider.ts | 7 + .../amplify/function.ts | 16 ++ .../amplify/test_factories.ts | 2 + 11 files changed, 365 insertions(+), 67 deletions(-) create mode 100644 .changeset/long-berries-greet.md create mode 100644 packages/backend-function/src/function_construct_base.ts create mode 100644 packages/backend-function/src/provided_function_factory.ts create mode 100644 packages/backend-function/src/resource_access_acceptor.ts create mode 100644 packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/func-src/handler_provider.ts diff --git a/.changeset/long-berries-greet.md b/.changeset/long-berries-greet.md new file mode 100644 index 00000000000..c0ad1b3e797 --- /dev/null +++ b/.changeset/long-berries-greet.md @@ -0,0 +1,6 @@ +--- +'@aws-amplify/backend-function': minor +'@aws-amplify/backend': minor +--- + +add custom provided function support to define function diff --git a/packages/backend-function/API.md b/packages/backend-function/API.md index 522b29166bd..671906009b8 100644 --- a/packages/backend-function/API.md +++ b/packages/backend-function/API.md @@ -6,8 +6,10 @@ import { AmplifyResourceGroupName } from '@aws-amplify/plugin-types'; import { BackendSecret } from '@aws-amplify/plugin-types'; +import { Construct } from 'constructs'; import { ConstructFactory } from '@aws-amplify/plugin-types'; import { FunctionResources } from '@aws-amplify/plugin-types'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { LogLevel } from '@aws-amplify/plugin-types'; import { LogRetention } from '@aws-amplify/plugin-types'; import { ResourceAccessAcceptorFactory } from '@aws-amplify/plugin-types'; @@ -61,8 +63,11 @@ type DataClientError = { // @public (undocumented) type DataClientReturn = T extends DataClientEnv ? DataClientConfig : DataClientError; -// @public -export const defineFunction: (props?: FunctionProps) => ConstructFactory & ResourceAccessAcceptorFactory & AddEnvironmentFactory & StackProvider>; +// @public (undocumented) +export function defineFunction(props?: FunctionProps): ConstructFactory & ResourceAccessAcceptorFactory & AddEnvironmentFactory & StackProvider>; + +// @public (undocumented) +export function defineFunction(provider: (scope: Construct) => IFunction, providerProps?: ProvidedFunctionProps): ConstructFactory & ResourceAccessAcceptorFactory & StackProvider>; // @public (undocumented) export type FunctionArchitecture = 'x86_64' | 'arm64'; @@ -135,6 +140,11 @@ type LibraryOptions = { // @public (undocumented) export type NodeVersion = 16 | 18 | 20 | 22; +// @public (undocumented) +export type ProvidedFunctionProps = { + resourceGroupName?: AmplifyResourceGroupName; +}; + // @public (undocumented) type ResourceConfig = { API: { diff --git a/packages/backend-function/src/factory.test.ts b/packages/backend-function/src/factory.test.ts index 568d77d2c37..2bf4610f941 100644 --- a/packages/backend-function/src/factory.test.ts +++ b/packages/backend-function/src/factory.test.ts @@ -24,6 +24,7 @@ import { Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import fsp from 'fs/promises'; import path from 'node:path'; import { AmplifyUserError } from '@aws-amplify/platform-core'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; const createStackAndSetContext = (): Stack => { const app = new App(); @@ -782,4 +783,35 @@ void describe('AmplifyFunctionFactory', () => { ); }); }); + + void describe('provided function runtime property', () => { + void it('sets valid runtime', () => { + const lambda = defineFunction((scope) => { + return new NodejsFunction(scope, 'nodejs-provided', { + entry: + './packages/backend-function/src/test-assets/default-lambda/handler.ts', + runtime: Runtime.NODEJS_22_X, + }); + }).getInstance(getInstanceProps); + const template = Template.fromStack(lambda.stack); + + template.hasResourceProperties('AWS::Lambda::Function', { + Runtime: Runtime.NODEJS_22_X.name, + }); + }); + + void it('provided function defaults to oldest runtime', () => { + const lambda = defineFunction((scope) => { + return new NodejsFunction(scope, 'nodejs-provided', { + entry: + './packages/backend-function/src/test-assets/default-lambda/handler.ts', + }); + }).getInstance(getInstanceProps); + const template = Template.fromStack(lambda.stack); + + template.hasResourceProperties('AWS::Lambda::Function', { + Runtime: Runtime.NODEJS_16_X.name, + }); + }); + }); }); diff --git a/packages/backend-function/src/factory.ts b/packages/backend-function/src/factory.ts index 66ea558704b..4c1be471e29 100644 --- a/packages/backend-function/src/factory.ts +++ b/packages/backend-function/src/factory.ts @@ -1,8 +1,4 @@ -import { - FunctionOutput, - functionOutputKey, -} from '@aws-amplify/backend-output-schemas'; -import { AttributionMetadataStorage } from '@aws-amplify/backend-output-storage'; +import { FunctionOutput } from '@aws-amplify/backend-output-schemas'; import { AmplifyUserError, CallerDirectoryExtractor, @@ -20,19 +16,19 @@ import { GenerateContainerEntryProps, LogLevel, LogRetention, + ResourceAccessAcceptor, ResourceAccessAcceptorFactory, ResourceNameValidator, ResourceProvider, - SsmEnvironmentEntry, StackProvider, } from '@aws-amplify/plugin-types'; import { Duration, Size, Stack, Tags } from 'aws-cdk-lib'; 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 { Architecture, CfnFunction, + IFunction, ILayerVersion, LayerVersion, Runtime, @@ -41,7 +37,6 @@ 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 * as path from 'path'; import { FunctionEnvironmentTranslator } from './function_env_translator.js'; @@ -49,8 +44,12 @@ import { FunctionEnvironmentTypeGenerator } from './function_env_type_generator. import { FunctionLayerArnParser } from './layer_parser.js'; import { convertLoggingOptionsToCDK } from './logging_options_parser.js'; import { convertFunctionSchedulesToRuleSchedules } from './schedule_parser.js'; - -const functionStackType = 'function-Lambda'; +import { + ProvidedFunctionFactory, + ProvidedFunctionProps, +} from './provided_function_factory.js'; +import { AmplifyFunctionBase } from './function_construct_base.js'; +import { FunctionResourceAccessAcceptor } from './resource_access_acceptor.js'; export type AddEnvironmentFactory = { addEnvironment: (key: string, value: string | BackendSecret) => void; @@ -74,17 +73,38 @@ export type FunctionLogLevel = Extract< >; export type FunctionLogRetention = LogRetention; -/** - * Entry point for defining a function in the Amplify ecosystem - */ -export const defineFunction = ( - props: FunctionProps = {} +export function defineFunction( + props?: FunctionProps ): ConstructFactory< ResourceProvider & ResourceAccessAcceptorFactory & AddEnvironmentFactory & StackProvider -> => new FunctionFactory(props, new Error().stack); +>; +export function defineFunction( + provider: (scope: Construct) => IFunction, + providerProps?: ProvidedFunctionProps +): ConstructFactory< + ResourceProvider & + ResourceAccessAcceptorFactory & + StackProvider +>; +/** + * Entry point for defining a function in the Amplify ecosystem + */ +// This is the "implementation overload", it's not visible in public api. +// We have to use function notation instead of arrow notation. +// Arrow notation does not support overloads. +// eslint-disable-next-line no-restricted-syntax +export function defineFunction( + propsOrProvider: FunctionProps | ((scope: Construct) => IFunction) = {}, + providerProps?: ProvidedFunctionProps +): unknown { + if (propsOrProvider && typeof propsOrProvider === 'function') { + return new ProvidedFunctionFactory(propsOrProvider, providerProps); + } + return new FunctionFactory(propsOrProvider, new Error().stack); +} export type FunctionProps = { /** @@ -507,14 +527,10 @@ class FunctionGenerator implements ConstructContainerEntryGenerator { } class AmplifyFunction - extends Construct - implements - ResourceProvider, - ResourceAccessAcceptorFactory, - AddEnvironmentFactory + extends AmplifyFunctionBase + implements AddEnvironmentFactory { readonly resources: FunctionResources; - readonly stack: Stack; private readonly functionEnvironmentTranslator: FunctionEnvironmentTranslator; constructor( scope: Construct, @@ -523,9 +539,7 @@ class AmplifyFunction backendSecretResolver: BackendSecretResolver, outputStorageStrategy: BackendOutputStorageStrategy ) { - super(scope, id); - - this.stack = Stack.of(scope); + super(scope, id, outputStorageStrategy); const runtime = nodeVersionMap[props.runtime]; @@ -647,52 +661,18 @@ class AmplifyFunction }, }; - this.storeOutput(outputStorageStrategy); - - new AttributionMetadataStorage().storeAttributionMetadata( - Stack.of(this), - functionStackType, - fileURLToPath(new URL('../package.json', import.meta.url)) - ); + this.storeOutput(); } addEnvironment = (key: string, value: string | BackendSecret) => { this.functionEnvironmentTranslator.addEnvironmentEntry(key, value); }; - getResourceAccessAcceptor = () => ({ - identifier: `${this.node.id}LambdaResourceAccessAcceptor`, - acceptResourceAccess: ( - policy: Policy, - ssmEnvironmentEntries: SsmEnvironmentEntry[] - ) => { - const role = this.resources.lambda.role; - if (!role) { - // This should never happen since we are using the Function L2 construct - throw new Error( - 'No execution role found to attach lambda permissions to' - ); - } - policy.attachToRole(role); - ssmEnvironmentEntries.forEach(({ name, path }) => { - this.functionEnvironmentTranslator.addSsmEnvironmentEntry(name, path); - }); - }, - }); - - /** - * Store storage outputs using provided strategy - */ - private storeOutput = ( - outputStorageStrategy: BackendOutputStorageStrategy - ): void => { - outputStorageStrategy.appendToBackendOutputList(functionOutputKey, { - version: '1', - payload: { - definedFunctions: this.resources.lambda.functionName, - }, - }); - }; + getResourceAccessAcceptor = (): ResourceAccessAcceptor => + new FunctionResourceAccessAcceptor( + this, + this.functionEnvironmentTranslator + ); } const isWholeNumberBetweenInclusive = ( diff --git a/packages/backend-function/src/function_construct_base.ts b/packages/backend-function/src/function_construct_base.ts new file mode 100644 index 00000000000..73de12e5dba --- /dev/null +++ b/packages/backend-function/src/function_construct_base.ts @@ -0,0 +1,58 @@ +import { Construct } from 'constructs'; +import { + BackendOutputStorageStrategy, + FunctionResources, + ResourceAccessAcceptor, + ResourceAccessAcceptorFactory, + ResourceProvider, +} from '@aws-amplify/plugin-types'; +import { Stack } from 'aws-cdk-lib'; +import { + FunctionOutput, + functionOutputKey, +} from '@aws-amplify/backend-output-schemas'; +import { AttributionMetadataStorage } from '@aws-amplify/backend-output-storage'; +import { fileURLToPath } from 'node:url'; + +const functionStackType = 'function-Lambda'; + +/** + * A base class for function constructs. + */ +export abstract class AmplifyFunctionBase + extends Construct + implements ResourceProvider, ResourceAccessAcceptorFactory +{ + readonly stack: Stack; + abstract resources: FunctionResources; + + abstract getResourceAccessAcceptor: () => ResourceAccessAcceptor; + + /** + * Creates base function construct. + */ + protected constructor( + scope: Construct, + id: string, + private readonly outputStorageStrategy: BackendOutputStorageStrategy + ) { + super(scope, id); + + this.stack = Stack.of(scope); + + new AttributionMetadataStorage().storeAttributionMetadata( + Stack.of(this), + functionStackType, + fileURLToPath(new URL('../package.json', import.meta.url)) + ); + } + + protected storeOutput = (): void => { + this.outputStorageStrategy.appendToBackendOutputList(functionOutputKey, { + version: '1', + payload: { + definedFunctions: this.resources.lambda.functionName, + }, + }); + }; +} diff --git a/packages/backend-function/src/index.ts b/packages/backend-function/src/index.ts index cb95b1ef8d8..8d9b853ad39 100644 --- a/packages/backend-function/src/index.ts +++ b/packages/backend-function/src/index.ts @@ -1 +1,3 @@ export * from './factory.js'; +import { ProvidedFunctionProps } from './provided_function_factory.js'; +export { ProvidedFunctionProps }; diff --git a/packages/backend-function/src/provided_function_factory.ts b/packages/backend-function/src/provided_function_factory.ts new file mode 100644 index 00000000000..2d6becb0117 --- /dev/null +++ b/packages/backend-function/src/provided_function_factory.ts @@ -0,0 +1,142 @@ +import { + AmplifyFunction, + AmplifyResourceGroupName, + BackendOutputStorageStrategy, + ConstructContainerEntryGenerator, + ConstructFactory, + ConstructFactoryGetInstanceProps, + FunctionResources, + GenerateContainerEntryProps, + ResourceAccessAcceptor, +} from '@aws-amplify/plugin-types'; +import { Construct } from 'constructs'; +import { CfnFunction, IFunction } from 'aws-cdk-lib/aws-lambda'; +import { FunctionOutput } from '@aws-amplify/backend-output-schemas'; +import { Tags } from 'aws-cdk-lib'; +import { AmplifyUserError, TagName } from '@aws-amplify/platform-core'; +import { AmplifyFunctionBase } from './function_construct_base.js'; +import { FunctionResourceAccessAcceptor } from './resource_access_acceptor.js'; + +export type ProvidedFunctionProps = { + /** + * Group the function with existing Amplify resources or separate the function into its own group. + * @default 'function' // grouping with other Amplify functions + * @example + * resourceGroupName: 'auth' // to group an auth trigger with an auth resource + */ + resourceGroupName?: AmplifyResourceGroupName; +}; + +/** + * Adapts provided CDK function as Amplify function. + */ +export class ProvidedFunctionFactory + implements ConstructFactory +{ + private generator: ConstructContainerEntryGenerator; + + /** + * Creates provided function factory. + */ + constructor( + private readonly functionProvider: (scope: Construct) => IFunction, + private readonly props?: ProvidedFunctionProps + ) {} + + /** + * Creates a function instance. + */ + getInstance(props: ConstructFactoryGetInstanceProps): AmplifyFunction { + if (!this.generator) { + this.generator = new ProvidedFunctionGenerator( + this.functionProvider, + props.outputStorageStrategy, + this.props + ); + } + return props.constructContainer.getOrCompute( + this.generator + ) as AmplifyFunction; + } +} + +class ProvidedFunctionGenerator implements ConstructContainerEntryGenerator { + readonly resourceGroupName: AmplifyResourceGroupName; + + constructor( + private readonly functionProvider: (scope: Construct) => IFunction, + private readonly outputStorageStrategy: BackendOutputStorageStrategy, + props?: ProvidedFunctionProps + ) { + this.resourceGroupName = props?.resourceGroupName ?? 'function'; + } + + generateContainerEntry = ({ scope }: GenerateContainerEntryProps) => { + let providedFunction: IFunction; + try { + providedFunction = this.functionProvider(scope); + } catch (e) { + if ( + e instanceof Error && + (e.message.includes('docker exited with status 1') || + e.message.includes('docker ENOENT')) + ) { + throw new AmplifyUserError( + 'CustomFunctionProviderDockerError', + { + message: 'Failed to instantiate custom function provider', + resolution: + 'See https://docs.amplify.aws/react/build-a-backend/functions/custom-functions for more details about current limitations and troubleshooting steps.', + }, + e + ); + } else { + throw new AmplifyUserError( + 'CustomFunctionProviderError', + { + message: 'Failed to instantiate custom function provider', + resolution: + 'Check the definition of your custom function provided in `defineFunction` and refer to the logs for more information. See https://docs.amplify.aws/react/build-a-backend/functions/custom-functions for more details.', + }, + e instanceof Error ? e : undefined + ); + } + } + return new ProvidedAmplifyFunction( + scope, + `${providedFunction.node.id}-provided`, + this.outputStorageStrategy, + providedFunction + ); + }; +} + +class ProvidedAmplifyFunction extends AmplifyFunctionBase { + readonly resources: FunctionResources; + constructor( + scope: Construct, + id: string, + outputStorageStrategy: BackendOutputStorageStrategy, + providedFunction: IFunction + ) { + super(scope, id, outputStorageStrategy); + + const cfnFunction = providedFunction.node.findChild( + 'Resource' + ) as CfnFunction; + + Tags.of(cfnFunction).add(TagName.FRIENDLY_NAME, providedFunction.node.id); + + this.resources = { + lambda: providedFunction, + cfnResources: { + cfnFunction, + }, + }; + + this.storeOutput(); + } + + getResourceAccessAcceptor = (): ResourceAccessAcceptor => + new FunctionResourceAccessAcceptor(this); +} diff --git a/packages/backend-function/src/resource_access_acceptor.ts b/packages/backend-function/src/resource_access_acceptor.ts new file mode 100644 index 00000000000..e3ba57aaf66 --- /dev/null +++ b/packages/backend-function/src/resource_access_acceptor.ts @@ -0,0 +1,43 @@ +import { + ResourceAccessAcceptor, + SsmEnvironmentEntry, +} from '@aws-amplify/plugin-types'; +import { FunctionEnvironmentTranslator } from './function_env_translator.js'; +import { AmplifyFunctionBase } from './function_construct_base.js'; +import { Policy } from 'aws-cdk-lib/aws-iam'; + +/** + * A function resource acceptor. + */ +export class FunctionResourceAccessAcceptor implements ResourceAccessAcceptor { + readonly identifier: string; + + /** + * Creates function resource acceptor. + */ + constructor( + private readonly func: AmplifyFunctionBase, + private readonly functionEnvironmentTranslator?: FunctionEnvironmentTranslator + ) { + this.identifier = `${func.node.id}LambdaResourceAccessAcceptor`; + } + + acceptResourceAccess = ( + policy: Policy, + ssmEnvironmentEntries: SsmEnvironmentEntry[] + ) => { + const role = this.func.resources.lambda.role; + if (!role) { + // This should never happen since we are using the Function L2 construct + throw new Error( + 'No execution role found to attach lambda permissions to' + ); + } + policy.attachToRole(role); + if (this.functionEnvironmentTranslator) { + for (const { name, path } of ssmEnvironmentEntries) { + this.functionEnvironmentTranslator.addSsmEnvironmentEntry(name, path); + } + } + }; +} diff --git a/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/func-src/handler_provider.ts b/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/func-src/handler_provider.ts new file mode 100644 index 00000000000..f7c71c983ca --- /dev/null +++ b/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/func-src/handler_provider.ts @@ -0,0 +1,7 @@ +/** + * This function is a simple hello world function. + * It's for not direct defineFunction, it's provided function + */ +export const handler = async () => { + return 'Hello from NodeJS Function!'; +}; diff --git a/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/function.ts b/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/function.ts index a4f30ef66f1..8784959dffc 100644 --- a/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/function.ts +++ b/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/function.ts @@ -1,4 +1,8 @@ import { defineFunction } from '@aws-amplify/backend'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; export const funcWithSsm = defineFunction({ name: 'funcWithSsm', @@ -24,6 +28,18 @@ export const funcNoMinify = defineFunction({ }, }); +export const funcProvided = defineFunction((scope) => { + return new NodejsFunction(scope, 'funcProvided', { + entry: path.resolve( + fileURLToPath(import.meta.url), + '..', + 'func-src', + 'handler_provider.ts' + ), + runtime: Runtime.NODEJS_18_X, + }); +}); + export const funcCustomEmailSender = defineFunction({ name: 'funcCustomEmailSender', entry: './func-src/handler_custom_email_sender.ts', diff --git a/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/test_factories.ts b/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/test_factories.ts index 4e72f34424b..4841663dc03 100644 --- a/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/test_factories.ts +++ b/packages/integration-tests/src/test-projects/advanced-auth-and-functions/amplify/test_factories.ts @@ -4,6 +4,7 @@ import { funcWithAwsSdk, funcWithSchedule, funcWithSsm, + funcProvided, } from './function.js'; import { auth } from './auth/resource.js'; @@ -14,4 +15,5 @@ export const authAndFunctions = { funcWithSchedule, funcNoMinify, funcCustomEmailSender, + funcProvided, };