diff --git a/packages/aws-cdk-lib/aws-lambda/lib/runtime.ts b/packages/aws-cdk-lib/aws-lambda/lib/runtime.ts index 0013d67ad2079..07d5e00c8e0d0 100644 --- a/packages/aws-cdk-lib/aws-lambda/lib/runtime.ts +++ b/packages/aws-cdk-lib/aws-lambda/lib/runtime.ts @@ -30,6 +30,12 @@ export interface LambdaRuntimeProps { * @default false */ readonly supportsSnapStart?: boolean; + + /** + * Whether this runtime is deprecated. + * @default false + */ + readonly isDeprecated?: boolean; } export enum RuntimeFamily { @@ -56,43 +62,64 @@ export class Runtime { * The NodeJS runtime (nodejs) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS = new Runtime('nodejs', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS = new Runtime('nodejs', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 4.3 runtime (nodejs4.3) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS_4_3 = new Runtime('nodejs4.3', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS_4_3 = new Runtime('nodejs4.3', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 6.10 runtime (nodejs6.10) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS_6_10 = new Runtime('nodejs6.10', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS_6_10 = new Runtime('nodejs6.10', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 8.10 runtime (nodejs8.10) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS_8_10 = new Runtime('nodejs8.10', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS_8_10 = new Runtime('nodejs8.10', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 10.x runtime (nodejs10.x) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS_10_X = new Runtime('nodejs10.x', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS_10_X = new Runtime('nodejs10.x', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 12.x runtime (nodejs12.x) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS_12_X = new Runtime('nodejs12.x', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS_12_X = new Runtime('nodejs12.x', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 14.x runtime (nodejs14.x) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest NodeJS runtime. */ - public static readonly NODEJS_14_X = new Runtime('nodejs14.x', RuntimeFamily.NODEJS, { supportsInlineCode: true }); + public static readonly NODEJS_14_X = new Runtime('nodejs14.x', RuntimeFamily.NODEJS, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The NodeJS 16.x runtime (nodejs16.x) @@ -119,7 +146,10 @@ export class Runtime { * The Python 2.7 runtime (python2.7) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest Python runtime. */ - public static readonly PYTHON_2_7 = new Runtime('python2.7', RuntimeFamily.PYTHON, { supportsInlineCode: true }); + public static readonly PYTHON_2_7 = new Runtime('python2.7', RuntimeFamily.PYTHON, { + supportsInlineCode: true, + isDeprecated: true, + }); /** * The Python 3.6 runtime (python3.6) (not recommended) @@ -131,6 +161,7 @@ export class Runtime { public static readonly PYTHON_3_6 = new Runtime('python3.6', RuntimeFamily.PYTHON, { supportsInlineCode: true, supportsCodeGuruProfiling: true, + isDeprecated: true, }); /** @@ -228,37 +259,49 @@ export class Runtime { * The .NET Core 1.0 runtime (dotnetcore1.0) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest .NET Core runtime. */ - public static readonly DOTNET_CORE_1 = new Runtime('dotnetcore1.0', RuntimeFamily.DOTNET_CORE); + public static readonly DOTNET_CORE_1 = new Runtime('dotnetcore1.0', RuntimeFamily.DOTNET_CORE, { + isDeprecated: true, + }); /** * The .NET Core 2.0 runtime (dotnetcore2.0) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest .NET Core runtime. */ - public static readonly DOTNET_CORE_2 = new Runtime('dotnetcore2.0', RuntimeFamily.DOTNET_CORE); + public static readonly DOTNET_CORE_2 = new Runtime('dotnetcore2.0', RuntimeFamily.DOTNET_CORE, { + isDeprecated: true, + }); /** * The .NET Core 2.1 runtime (dotnetcore2.1) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest .NET Core runtime. */ - public static readonly DOTNET_CORE_2_1 = new Runtime('dotnetcore2.1', RuntimeFamily.DOTNET_CORE); + public static readonly DOTNET_CORE_2_1 = new Runtime('dotnetcore2.1', RuntimeFamily.DOTNET_CORE, { + isDeprecated: true, + }); /** * The .NET Core 3.1 runtime (dotnetcore3.1) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest .NET Core runtime. */ - public static readonly DOTNET_CORE_3_1 = new Runtime('dotnetcore3.1', RuntimeFamily.DOTNET_CORE); + public static readonly DOTNET_CORE_3_1 = new Runtime('dotnetcore3.1', RuntimeFamily.DOTNET_CORE, { + isDeprecated: true, + }); /** * The Go 1.x runtime (go1.x) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the PROVIDED_AL2023 runtime. */ - public static readonly GO_1_X = new Runtime('go1.x', RuntimeFamily.GO); + public static readonly GO_1_X = new Runtime('go1.x', RuntimeFamily.GO, { + isDeprecated: true, + }); /** * The Ruby 2.5 runtime (ruby2.5) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest Ruby runtime. */ - public static readonly RUBY_2_5 = new Runtime('ruby2.5', RuntimeFamily.RUBY); + public static readonly RUBY_2_5 = new Runtime('ruby2.5', RuntimeFamily.RUBY, { + isDeprecated: true, + }); /** * The Ruby 2.7 runtime (ruby2.7) @@ -274,7 +317,9 @@ export class Runtime { * The custom provided runtime (provided) * @deprecated Legacy runtime no longer supported by AWS Lambda. Migrate to the latest provided.al2023 runtime. */ - public static readonly PROVIDED = new Runtime('provided', RuntimeFamily.OTHER); + public static readonly PROVIDED = new Runtime('provided', RuntimeFamily.OTHER, { + isDeprecated: true, + }); /** * The custom provided runtime with Amazon Linux 2 (provided.al2) @@ -333,11 +378,17 @@ export class Runtime { */ public readonly isVariable: boolean; + /** + * Whether the runtime is deprecated. + */ + public readonly isDeprecated: boolean; + constructor(name: string, family?: RuntimeFamily, props: LambdaRuntimeProps = {}) { this.name = name; this.supportsInlineCode = !!props.supportsInlineCode; this.family = family; this.isVariable = !!props.isVariable; + this.isDeprecated = props.isDeprecated ?? false; const imageName = props.bundlingDockerImage ?? `public.ecr.aws/sam/build-${name}`; this.bundlingDockerImage = DockerImage.fromRegistry(imageName); diff --git a/packages/aws-cdk-lib/aws-lambda/test/runtime.test.ts b/packages/aws-cdk-lib/aws-lambda/test/runtime.test.ts index f3976e70c4327..5a6a421a9cb80 100644 --- a/packages/aws-cdk-lib/aws-lambda/test/runtime.test.ts +++ b/packages/aws-cdk-lib/aws-lambda/test/runtime.test.ts @@ -55,3 +55,26 @@ describe('runtime', () => { expect(runtime.bundlingDockerImage.image).toEqual('my-docker-image'); }); }); + +describe('deprecated runtimes', () => { + test.each([ + [lambda.Runtime.PYTHON_2_7], + [lambda.Runtime.PYTHON_3_6], + [lambda.Runtime.NODEJS], + [lambda.Runtime.NODEJS_4_3], + [lambda.Runtime.NODEJS_6_10], + [lambda.Runtime.NODEJS_8_10], + [lambda.Runtime.NODEJS_10_X], + [lambda.Runtime.NODEJS_12_X], + [lambda.Runtime.NODEJS_14_X], + [lambda.Runtime.DOTNET_CORE_1], + [lambda.Runtime.DOTNET_CORE_2], + [lambda.Runtime.DOTNET_CORE_2_1], + [lambda.Runtime.DOTNET_CORE_3_1], + [lambda.Runtime.GO_1_X], + [lambda.Runtime.RUBY_2_5], + [lambda.Runtime.PROVIDED], + ])('%s is deprecated', (runtime) => { + expect(runtime.isDeprecated).toEqual(true); + }); +}); diff --git a/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider-base.ts b/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider-base.ts new file mode 100644 index 0000000000000..ecd8f72f42d41 --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider-base.ts @@ -0,0 +1,281 @@ +import * as path from 'path'; +import { Construct } from 'constructs'; +import * as fs from 'fs-extra'; +import { CustomResourceProviderOptions, INLINE_CUSTOM_RESOURCE_CONTEXT } from './shared'; +import * as cxapi from '../../../cx-api'; +import { AssetStaging } from '../asset-staging'; +import { FileAssetPackaging } from '../assets'; +import { CfnResource } from '../cfn-resource'; +import { Duration } from '../duration'; +import { FileSystem } from '../fs'; +import { PolicySynthesizer, getPrecreatedRoleConfig } from '../helpers-internal'; +import { Lazy } from '../lazy'; +import { Size } from '../size'; +import { Stack } from '../stack'; +import { Token } from '../token'; + +const ENTRYPOINT_FILENAME = '__entrypoint__'; +const ENTRYPOINT_NODEJS_SOURCE = path.join(__dirname, '..', '..', '..', 'custom-resource-handlers', 'dist', 'core', 'nodejs-entrypoint-handler', 'index.js'); + +/** + * Initialization properties for `CustomResourceProviderBase` + */ +export interface CustomResourceProviderBaseProps extends CustomResourceProviderOptions { + /** + * A local file system directory with the provider's code. The code will be + * bundled into a zip asset and wired to the provider's AWS Lambda function. + */ + readonly codeDirectory: string; + + /** + * The AWS Lambda runtime and version name to use for the provider. + */ + readonly runtimeName: string; +} + +/** + * Base class for creating a custom resource provider + */ +export abstract class CustomResourceProviderBase extends Construct { + /** + * The hash of the lambda code backing this provider. Can be used to trigger updates + * on code changes, even when the properties of a custom resource remain unchanged. + */ + public get codeHash(): string { + if (!this._codeHash) { + throw new Error('This custom resource uses inlineCode: true and does not have a codeHash'); + } + return this._codeHash; + } + + private _codeHash?: string; + private policyStatements?: any[]; + private role?: CfnResource; + + /** + * The ARN of the provider's AWS Lambda function which should be used as the `serviceToken` when defining a custom + * resource. + */ + public readonly serviceToken: string; + + /** + * The ARN of the provider's AWS Lambda function role. + */ + public readonly roleArn: string; + + protected constructor(scope: Construct, id: string, props: CustomResourceProviderBaseProps) { + super(scope, id); + + const stack = Stack.of(scope); + + // verify we have an index file there + if (!fs.existsSync(path.join(props.codeDirectory, 'index.js'))) { + throw new Error(`cannot find ${props.codeDirectory}/index.js`); + } + + if (props.policyStatements) { + for (const statement of props.policyStatements) { + this.addToRolePolicy(statement); + } + } + + const { code, codeHandler, metadata } = this.createCodePropAndMetadata(props, stack); + + const config = getPrecreatedRoleConfig(this, `${this.node.path}/Role`); + const assumeRolePolicyDoc = [{ Action: 'sts:AssumeRole', Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' } }]; + const managedPolicyArn = 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'; + + // need to initialize this attribute, but there should never be an instance + // where config.enabled=true && config.preventSynthesis=true + this.roleArn = ''; + if (config.enabled) { + // gives policyStatements a chance to resolve + this.node.addValidation({ + validate: () => { + PolicySynthesizer.getOrCreate(this).addRole(`${this.node.path}/Role`, { + missing: !config.precreatedRoleName, + roleName: config.precreatedRoleName ?? id+'Role', + managedPolicies: [{ managedPolicyArn: managedPolicyArn }], + policyStatements: this.policyStatements ?? [], + assumeRolePolicy: assumeRolePolicyDoc as any, + }); + return []; + }, + }); + this.roleArn = Stack.of(this).formatArn({ + region: '', + service: 'iam', + resource: 'role', + resourceName: config.precreatedRoleName, + }); + } + if (!config.preventSynthesis) { + this.role = new CfnResource(this, 'Role', { + type: 'AWS::IAM::Role', + properties: { + AssumeRolePolicyDocument: { + Version: '2012-10-17', + Statement: assumeRolePolicyDoc, + }, + ManagedPolicyArns: [ + { 'Fn::Sub': managedPolicyArn }, + ], + Policies: Lazy.any({ produce: () => this.renderPolicies() }), + }, + }); + this.roleArn = Token.asString(this.role.getAtt('Arn')); + } + + const timeout = props.timeout ?? Duration.minutes(15); + const memory = props.memorySize ?? Size.mebibytes(128); + + const handler = new CfnResource(this, 'Handler', { + type: 'AWS::Lambda::Function', + properties: { + Code: code, + Timeout: timeout.toSeconds(), + MemorySize: memory.toMebibytes(), + Handler: codeHandler, + Role: this.roleArn, + Runtime: props.runtimeName, + Environment: this.renderEnvironmentVariables(props.environment), + Description: props.description ?? undefined, + }, + }); + + if (this.role) { + handler.addDependency(this.role); + } + + if (metadata) { + Object.entries(metadata).forEach(([k, v]) => handler.addMetadata(k, v)); + } + + this.serviceToken = Token.asString(handler.getAtt('Arn')); + } + + /** + * Add an IAM policy statement to the inline policy of the + * provider's lambda function's role. + * + * **Please note**: this is a direct IAM JSON policy blob, *not* a `iam.PolicyStatement` + * object like you will see in the rest of the CDK. + * + * + * @example + * declare const myProvider: CustomResourceProvider; + * + * myProvider.addToRolePolicy({ + * Effect: 'Allow', + * Action: 's3:GetObject', + * Resource: '*', + * }); + */ + public addToRolePolicy(statement: any): void { + if (!this.policyStatements) { + this.policyStatements = []; + } + this.policyStatements.push(statement); + } + + private renderPolicies() { + if (!this.policyStatements) { + return undefined; + } + + const policies = [{ + PolicyName: 'Inline', + PolicyDocument: { + Version: '2012-10-17', + Statement: this.policyStatements, + }, + }]; + + return policies; + } + + private renderEnvironmentVariables(env?: { [key: string]: string }) { + if (!env || Object.keys(env).length === 0) { + return undefined; + } + + env = { ...env }; // Copy + + // Always use regional endpoints + env.AWS_STS_REGIONAL_ENDPOINTS = 'regional'; + + // Sort environment so the hash of the function used to create + // `currentVersion` is not affected by key order (this is how lambda does + // it) + const variables: { [key: string]: string } = {}; + const keys = Object.keys(env).sort(); + + for (const key of keys) { + variables[key] = env[key]; + } + + return { Variables: variables }; + } + + /** + * Returns the code property for the custom resource as well as any metadata. + * If the code is to be uploaded as an asset, the asset gets created in this function. + */ + private createCodePropAndMetadata(props: CustomResourceProviderBaseProps, stack: Stack): { + code: Code, + codeHandler: string, + metadata?: {[key: string]: string}, + } { + let codeHandler = 'index.handler'; + const inlineCode = this.node.tryGetContext(INLINE_CUSTOM_RESOURCE_CONTEXT); + if (!inlineCode) { + const stagingDirectory = FileSystem.mkdtemp('cdk-custom-resource'); + fs.copySync(props.codeDirectory, stagingDirectory, { filter: (src, _dest) => !src.endsWith('.ts') }); + + if (props.useCfnResponseWrapper ?? true) { + fs.copyFileSync(ENTRYPOINT_NODEJS_SOURCE, path.join(stagingDirectory, `${ENTRYPOINT_FILENAME}.js`)); + codeHandler = `${ENTRYPOINT_FILENAME}.handler`; + } + + const staging = new AssetStaging(this, 'Staging', { + sourcePath: stagingDirectory, + }); + + const assetFileName = staging.relativeStagedPath(stack); + + const asset = stack.synthesizer.addFileAsset({ + fileName: assetFileName, + sourceHash: staging.assetHash, + packaging: FileAssetPackaging.ZIP_DIRECTORY, + }); + + this._codeHash = staging.assetHash; + + return { + code: { + S3Bucket: asset.bucketName, + S3Key: asset.objectKey, + }, + codeHandler, + metadata: this.node.tryGetContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT) ? { + [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: assetFileName, + [cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code', + } : undefined, + }; + } + + return { + code: { + ZipFile: fs.readFileSync(path.join(props.codeDirectory, 'index.js'), 'utf-8'), + }, + codeHandler, + }; + } +} + +export type Code = { + ZipFile: string, +} | { + S3Bucket: string, + S3Key: string, +}; diff --git a/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.ts b/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.ts index e2a15e3211263..1b0dd0e81a984 100644 --- a/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.ts +++ b/packages/aws-cdk-lib/core/lib/custom-resource-provider/custom-resource-provider.ts @@ -1,37 +1,13 @@ -import * as path from 'path'; import { Construct } from 'constructs'; -import * as fs from 'fs-extra'; -import * as cxapi from '../../../cx-api'; -import { AssetStaging } from '../asset-staging'; -import { FileAssetPackaging } from '../assets'; -import { CfnResource } from '../cfn-resource'; -import { Duration } from '../duration'; -import { FileSystem } from '../fs'; -import { PolicySynthesizer, getPrecreatedRoleConfig } from '../helpers-internal'; -import { Lazy } from '../lazy'; -import { Size } from '../size'; +import { CustomResourceProviderBase } from './custom-resource-provider-base'; +import { CustomResourceProviderOptions } from './shared'; import { Stack } from '../stack'; -import { Token } from '../token'; - -const ENTRYPOINT_FILENAME = '__entrypoint__'; -const ENTRYPOINT_NODEJS_SOURCE = path.join(__dirname, '..', '..', '..', 'custom-resource-handlers', 'dist', 'core', 'nodejs-entrypoint-handler', 'index.js'); -export const INLINE_CUSTOM_RESOURCE_CONTEXT = '@aws-cdk/core:inlineCustomResourceIfPossible'; /** * Initialization properties for `CustomResourceProvider`. * */ -export interface CustomResourceProviderProps { - /** - * Whether or not the cloudformation response wrapper (`nodejs-entrypoint.ts`) is used. - * If set to `true`, `nodejs-entrypoint.js` is bundled in the same asset as the custom resource - * and set as the entrypoint. If set to `false`, the custom resource provided is the - * entrypoint. - * - * @default - `true` if `inlineCode: false` and `false` otherwise. - */ - readonly useCfnResponseWrapper?: boolean; - +export interface CustomResourceProviderProps extends CustomResourceProviderOptions { /** * A local file system directory with the provider's code. The code will be * bundled into a zip asset and wired to the provider's AWS Lambda function. @@ -42,59 +18,6 @@ export interface CustomResourceProviderProps { * The AWS Lambda runtime and version to use for the provider. */ readonly runtime: CustomResourceProviderRuntime; - - /** - * A set of IAM policy statements to include in the inline policy of the - * provider's lambda function. - * - * **Please note**: these are direct IAM JSON policy blobs, *not* `iam.PolicyStatement` - * objects like you will see in the rest of the CDK. - * - * @default - no additional inline policy - * - * @example - * const provider = CustomResourceProvider.getOrCreateProvider(this, 'Custom::MyCustomResourceType', { - * codeDirectory: `${__dirname}/my-handler`, - * runtime: CustomResourceProviderRuntime.NODEJS_18_X, - * policyStatements: [ - * { - * Effect: 'Allow', - * Action: 's3:PutObject*', - * Resource: '*', - * } - * ], - * }); - */ - readonly policyStatements?: any[]; - - /** - * AWS Lambda timeout for the provider. - * - * @default Duration.minutes(15) - */ - readonly timeout?: Duration; - - /** - * The amount of memory that your function has access to. Increasing the - * function's memory also increases its CPU allocation. - * - * @default Size.mebibytes(128) - */ - readonly memorySize?: Size; - - /** - * Key-value pairs that are passed to Lambda as Environment - * - * @default - No environment variables. - */ - readonly environment?: { [key: string]: string }; - - /** - * A description of the function. - * - * @default - No description. - */ - readonly description?: string; } /** @@ -154,7 +77,7 @@ export enum CustomResourceProviderRuntime { * in that module a read, regardless of whether you end up using the Provider * class in there or this one. */ -export class CustomResourceProvider extends Construct { +export class CustomResourceProvider extends CustomResourceProviderBase { /** * Returns a stack-level singleton ARN (service token) for the custom resource * provider. @@ -187,255 +110,14 @@ export class CustomResourceProvider extends Construct { const stack = Stack.of(scope); const provider = stack.node.tryFindChild(id) as CustomResourceProvider ?? new CustomResourceProvider(stack, id, props); - return provider; } - /** - * The ARN of the provider's AWS Lambda function which should be used as the - * `serviceToken` when defining a custom resource. - * - * @example - * declare const myProvider: CustomResourceProvider; - * - * new CustomResource(this, 'MyCustomResource', { - * serviceToken: myProvider.serviceToken, - * properties: { - * myPropertyOne: 'one', - * myPropertyTwo: 'two', - * }, - * }); - */ - public readonly serviceToken: string; - - /** - * The ARN of the provider's AWS Lambda function role. - */ - public readonly roleArn: string; - - /** - * The hash of the lambda code backing this provider. Can be used to trigger updates - * on code changes, even when the properties of a custom resource remain unchanged. - */ - public get codeHash(): string { - if (!this._codeHash) { - throw new Error('This custom resource uses inlineCode: true and does not have a codeHash'); - } - return this._codeHash; - } - - private _codeHash?: string; - - private policyStatements?: any[]; - private _role?: CfnResource; - protected constructor(scope: Construct, id: string, props: CustomResourceProviderProps) { - super(scope, id); - - const stack = Stack.of(scope); - - // verify we have an index file there - if (!fs.existsSync(path.join(props.codeDirectory, 'index.js'))) { - throw new Error(`cannot find ${props.codeDirectory}/index.js`); - } - - const { code, codeHandler, metadata } = this.createCodePropAndMetadata(props, stack); - - if (props.policyStatements) { - for (const statement of props.policyStatements) { - this.addToRolePolicy(statement); - } - } - - const config = getPrecreatedRoleConfig(this, `${this.node.path}/Role`); - const assumeRolePolicyDoc = [{ Action: 'sts:AssumeRole', Effect: 'Allow', Principal: { Service: 'lambda.amazonaws.com' } }]; - const managedPolicyArn = 'arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'; - - // need to initialize this attribute, but there should never be an instance - // where config.enabled=true && config.preventSynthesis=true - this.roleArn = ''; - if (config.enabled) { - // gives policyStatements a chance to resolve - this.node.addValidation({ - validate: () => { - PolicySynthesizer.getOrCreate(this).addRole(`${this.node.path}/Role`, { - missing: !config.precreatedRoleName, - roleName: config.precreatedRoleName ?? id+'Role', - managedPolicies: [{ managedPolicyArn: managedPolicyArn }], - policyStatements: this.policyStatements ?? [], - assumeRolePolicy: assumeRolePolicyDoc as any, - }); - return []; - }, - }); - this.roleArn = Stack.of(this).formatArn({ - region: '', - service: 'iam', - resource: 'role', - resourceName: config.precreatedRoleName, - }); - } - if (!config.preventSynthesis) { - this._role = new CfnResource(this, 'Role', { - type: 'AWS::IAM::Role', - properties: { - AssumeRolePolicyDocument: { - Version: '2012-10-17', - Statement: assumeRolePolicyDoc, - }, - ManagedPolicyArns: [ - { 'Fn::Sub': managedPolicyArn }, - ], - Policies: Lazy.any({ produce: () => this.renderPolicies() }), - }, - }); - this.roleArn = Token.asString(this._role.getAtt('Arn')); - } - - const timeout = props.timeout ?? Duration.minutes(15); - const memory = props.memorySize ?? Size.mebibytes(128); - - const handler = new CfnResource(this, 'Handler', { - type: 'AWS::Lambda::Function', - properties: { - Code: code, - Timeout: timeout.toSeconds(), - MemorySize: memory.toMebibytes(), - Handler: codeHandler, - Role: this.roleArn, - Runtime: customResourceProviderRuntimeToString(props.runtime), - Environment: this.renderEnvironmentVariables(props.environment), - Description: props.description ?? undefined, - }, + super(scope, id, { + ...props, + runtimeName: customResourceProviderRuntimeToString(props.runtime), }); - - if (this._role) { - handler.addDependency(this._role); - } - - if (metadata) { - Object.entries(metadata).forEach(([k, v]) => handler.addMetadata(k, v)); - } - - this.serviceToken = Token.asString(handler.getAtt('Arn')); - } - - /** - * Returns the code property for the custom resource as well as any metadata. - * If the code is to be uploaded as an asset, the asset gets created in this function. - */ - private createCodePropAndMetadata(props: CustomResourceProviderProps, stack: Stack): { - code: Code, - codeHandler: string, - metadata?: {[key: string]: string}, - } { - let codeHandler = 'index.handler'; - const inlineCode = this.node.tryGetContext(INLINE_CUSTOM_RESOURCE_CONTEXT); - if (!inlineCode) { - const stagingDirectory = FileSystem.mkdtemp('cdk-custom-resource'); - fs.copySync(props.codeDirectory, stagingDirectory, { filter: (src, _dest) => !src.endsWith('.ts') }); - - if (props.useCfnResponseWrapper ?? true) { - fs.copyFileSync(ENTRYPOINT_NODEJS_SOURCE, path.join(stagingDirectory, `${ENTRYPOINT_FILENAME}.js`)); - codeHandler = `${ENTRYPOINT_FILENAME}.handler`; - } - - const staging = new AssetStaging(this, 'Staging', { - sourcePath: stagingDirectory, - }); - - const assetFileName = staging.relativeStagedPath(stack); - - const asset = stack.synthesizer.addFileAsset({ - fileName: assetFileName, - sourceHash: staging.assetHash, - packaging: FileAssetPackaging.ZIP_DIRECTORY, - }); - - this._codeHash = staging.assetHash; - - return { - code: { - S3Bucket: asset.bucketName, - S3Key: asset.objectKey, - }, - codeHandler, - metadata: this.node.tryGetContext(cxapi.ASSET_RESOURCE_METADATA_ENABLED_CONTEXT) ? { - [cxapi.ASSET_RESOURCE_METADATA_PATH_KEY]: assetFileName, - [cxapi.ASSET_RESOURCE_METADATA_PROPERTY_KEY]: 'Code', - } : undefined, - }; - } - - return { - code: { - ZipFile: fs.readFileSync(path.join(props.codeDirectory, 'index.js'), 'utf-8'), - }, - codeHandler, - }; - } - - /** - * Add an IAM policy statement to the inline policy of the - * provider's lambda function's role. - * - * **Please note**: this is a direct IAM JSON policy blob, *not* a `iam.PolicyStatement` - * object like you will see in the rest of the CDK. - * - * - * @example - * declare const myProvider: CustomResourceProvider; - * - * myProvider.addToRolePolicy({ - * Effect: 'Allow', - * Action: 's3:GetObject', - * Resource: '*', - * }); - */ - public addToRolePolicy(statement: any): void { - if (!this.policyStatements) { - this.policyStatements = []; - } - this.policyStatements.push(statement); - } - - private renderPolicies() { - if (!this.policyStatements) { - return undefined; - } - - const policies = [{ - PolicyName: 'Inline', - PolicyDocument: { - Version: '2012-10-17', - Statement: this.policyStatements, - }, - }]; - - return policies; - } - - private renderEnvironmentVariables(env?: { [key: string]: string }) { - if (!env || Object.keys(env).length === 0) { - return undefined; - } - - env = { ...env }; // Copy - - // Always use regional endpoints - env.AWS_STS_REGIONAL_ENDPOINTS = 'regional'; - - // Sort environment so the hash of the function used to create - // `currentVersion` is not affected by key order (this is how lambda does - // it) - const variables: { [key: string]: string } = {}; - const keys = Object.keys(env).sort(); - - for (const key of keys) { - variables[key] = env[key]; - } - - return { Variables: variables }; } } @@ -452,10 +134,3 @@ function customResourceProviderRuntimeToString(x: CustomResourceProviderRuntime) return 'nodejs18.x'; } } - -type Code = { - ZipFile: string, -} | { - S3Bucket: string, - S3Key: string, -}; diff --git a/packages/aws-cdk-lib/core/lib/custom-resource-provider/index.ts b/packages/aws-cdk-lib/core/lib/custom-resource-provider/index.ts index 9ff36ec201b71..95841d8fa7525 100644 --- a/packages/aws-cdk-lib/core/lib/custom-resource-provider/index.ts +++ b/packages/aws-cdk-lib/core/lib/custom-resource-provider/index.ts @@ -1 +1,3 @@ -export * from './custom-resource-provider'; \ No newline at end of file +export * from './custom-resource-provider-base'; +export * from './custom-resource-provider'; +export * from './shared'; \ No newline at end of file diff --git a/packages/aws-cdk-lib/core/lib/custom-resource-provider/shared.ts b/packages/aws-cdk-lib/core/lib/custom-resource-provider/shared.ts new file mode 100644 index 0000000000000..0f5f13e250afc --- /dev/null +++ b/packages/aws-cdk-lib/core/lib/custom-resource-provider/shared.ts @@ -0,0 +1,72 @@ +import { Duration } from '../duration'; +import { Size } from '../size'; + +export const INLINE_CUSTOM_RESOURCE_CONTEXT = '@aws-cdk/core:inlineCustomResourceIfPossible'; + +/** + * Initialization options for custom resource providers + */ +export interface CustomResourceProviderOptions { + /** + * Whether or not the cloudformation response wrapper (`nodejs-entrypoint.ts`) is used. + * If set to `true`, `nodejs-entrypoint.js` is bundled in the same asset as the custom resource + * and set as the entrypoint. If set to `false`, the custom resource provided is the + * entrypoint. + * + * @default - `true` if `inlineCode: false` and `false` otherwise. + */ + readonly useCfnResponseWrapper?: boolean; + + /** + * A set of IAM policy statements to include in the inline policy of the + * provider's lambda function. + * + * **Please note**: these are direct IAM JSON policy blobs, *not* `iam.PolicyStatement` + * objects like you will see in the rest of the CDK. + * + * @default - no additional inline policy + * + * @example + * const provider = CustomResourceProvider.getOrCreateProvider(this, 'Custom::MyCustomResourceType', { + * codeDirectory: `${__dirname}/my-handler`, + * runtime: CustomResourceProviderRuntime.NODEJS_18_X, + * policyStatements: [ + * { + * Effect: 'Allow', + * Action: 's3:PutObject*', + * Resource: '*', + * } + * ], + * }); + */ + readonly policyStatements?: any[]; + + /** + * AWS Lambda timeout for the provider. + * + * @default Duration.minutes(15) + */ + readonly timeout?: Duration; + + /** + * The amount of memory that your function has access to. Increasing the + * function's memory also increases its CPU allocation. + * + * @default Size.mebibytes(128) + */ + readonly memorySize?: Size; + + /** + * Key-value pairs that are passed to Lambda as Environment + * + * @default - No environment variables. + */ + readonly environment?: { [key: string]: string }; + + /** + * A description of the function. + * + * @default - No description. + */ + readonly description?: string; +} diff --git a/packages/aws-cdk-lib/handler-framework/README.md b/packages/aws-cdk-lib/handler-framework/README.md new file mode 100644 index 0000000000000..6bc0a9d9e83c1 --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/README.md @@ -0,0 +1,29 @@ +# AWS CDK Vended Handler Framework + +Note: This is framework intended for internal use only. + +The handler framework module is an internal framework used to establish best practices for vending Lambda handlers that are deployed to user accounts. Primarily, this framework includes a centralized definition of the default runtime version which is the latest version of NodeJs available across all AWS Regions. + +In addition to including a default runtime version, this framework forces the user to specify `compatibleRuntimes` for each Lambda handler being used. The framework first checks for the default runtime in the list of `compatibleRuntimes`. If found, the default runtime is used. If not found, the framework will look for the latest defined runtime in the list of `compatibleRuntimes`. If the latest runtime found is marked as deprecated, then the framework will force the build to fail. To continue, the user must specify a non-deprecated runtime version that the handler code is compatible with. + +## CDK Handler + +`CdkHandler` is a class that represents the source code that will be executed within a Lambda `Function` acting as a custom resource provider. Once constructed, this class contains four attributes: +1. `codeDirectory` - the local file system directory with the provider's code. This the code that will be bundled into a zip asset and wired to the provider's AWS Lambda function. +2. `code` - the source code that is loaded from a local disk path +3. `entrypoint` - the name of the method within your `code` that Lambda calls to execute your `Function`. Note that the default entrypoint is 'index.handler' +4. `compatibleRuntimes` - the runtimes that your `code` is compatible with + +Note that `compatibleRuntimes` can be any python or nodejs runtimes, but the nodejs runtime family is preferred. Python runtimes are supported to provide support for legacy handler code that was written using python. + +The following is an example of how to use `CdkHandler`: + +```ts +const stack = new Stack(); + +const handler = new CdkHandler(stack, 'Handler', { + codeDirectory: path.join(__dirname, 'my-handler'), + entrypoint: 'index.onEventHandler', + compatibleRuntimes: [Runtime.NODEJS_16_X, Runtime.NODEJS_18_X], +}); +``` diff --git a/packages/aws-cdk-lib/handler-framework/lib/cdk-handler.ts b/packages/aws-cdk-lib/handler-framework/lib/cdk-handler.ts new file mode 100644 index 0000000000000..d82edcfe9bde2 --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/lib/cdk-handler.ts @@ -0,0 +1,51 @@ +import { Construct } from 'constructs'; +import { RuntimeDeterminer } from './utils/runtime-determiner'; +import { Code, Runtime } from '../../aws-lambda'; + +/** + * Properties used to initialize `CdkHandler`. + */ +export interface CdkHandlerProps { + /** + * A local file system directory with the provider's code. The code will be + * bundled into a zip asset and wired to the provider's AWS Lambda function. + */ + readonly codeDirectory: string; + + /** + * Runtimes that are compatible with the source code. + */ + readonly compatibleRuntimes: Runtime[]; +} + +/** + * Represents an instance of `CdkHandler`. + */ +export class CdkHandler extends Construct { + /** + * The latest nodejs runtime version available across all AWS regions + */ + private static readonly DEFAULT_RUNTIME = Runtime.NODEJS_LATEST; + + /** + * The local file system directory with the provider's code. + */ + public readonly codeDirectory: string; + + /** + * The source code of your Lambda function. + */ + public readonly code: Code; + + /** + * The latest runtime that is compatible with the source code. + */ + public readonly runtime: Runtime; + + public constructor(scope: Construct, id: string, props: CdkHandlerProps) { + super(scope, id); + this.codeDirectory = props.codeDirectory; + this.code = Code.fromAsset(props.codeDirectory); + this.runtime = RuntimeDeterminer.determineLatestRuntime(CdkHandler.DEFAULT_RUNTIME, props.compatibleRuntimes); + } +} diff --git a/packages/aws-cdk-lib/handler-framework/lib/utils/runtime-determiner.ts b/packages/aws-cdk-lib/handler-framework/lib/utils/runtime-determiner.ts new file mode 100644 index 0000000000000..c36d831e37aa9 --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/lib/utils/runtime-determiner.ts @@ -0,0 +1,108 @@ +import { Runtime, RuntimeFamily } from '../../../aws-lambda'; + +/** + * A utility class used to determine the latest runtime for a specific runtime family + */ +export class RuntimeDeterminer { + /** + * Determines the latest runtime from a list of runtimes. + * + * Note: runtimes must only be nodejs or python. Nodejs runtimes will be given preference over + * python runtimes. + * + * @param runtimes the list of runtimes to search in + * @returns the latest nodejs or python runtime found, otherwise undefined if no nodejs or python + * runtimes are specified + */ + public static determineLatestRuntime(defaultRuntime: Runtime, runtimes: Runtime[]) { + if (runtimes.length === 0) { + throw new Error('You must specify at least one compatible runtime'); + } + + const nodeJsRuntimes = runtimes.filter(runtime => runtime.family === RuntimeFamily.NODEJS); + const latestNodeJsRuntime = RuntimeDeterminer.latestNodeJsRuntime(defaultRuntime, nodeJsRuntimes); + if (latestNodeJsRuntime !== undefined) { + if (latestNodeJsRuntime.isDeprecated) { + throw new Error(`Latest nodejs runtime ${latestNodeJsRuntime} is deprecated. You must upgrade to the latest code compatible nodejs runtime`); + } + return latestNodeJsRuntime; + } + + const pythonRuntimes = runtimes.filter(runtime => runtime.family === RuntimeFamily.PYTHON); + const latestPythonRuntime = RuntimeDeterminer.latestPythonRuntime(pythonRuntimes); + if (latestPythonRuntime !== undefined) { + if (latestPythonRuntime.isDeprecated) { + throw new Error(`Latest python runtime ${latestPythonRuntime} is deprecated. You must upgrade to the latest code compatible python runtime`); + } + return latestPythonRuntime; + } + + throw new Error('Compatible runtimes must contain only nodejs or python runtimes'); + } + + private static latestNodeJsRuntime(defaultRuntime: Runtime, nodeJsRuntimes: Runtime[]) { + if (nodeJsRuntimes.length === 0) { + return undefined; + } + + if (nodeJsRuntimes.some(runtime => runtime.runtimeEquals(defaultRuntime))) { + return defaultRuntime; + } + + let latestRuntime = nodeJsRuntimes[0]; + for (let idx = 1; idx < nodeJsRuntimes.length; idx++) { + latestRuntime = RuntimeDeterminer.latestRuntime(latestRuntime, nodeJsRuntimes[idx], RuntimeFamily.NODEJS); + } + + return latestRuntime; + } + + private static latestPythonRuntime(pythonRuntimes: Runtime[]) { + if (pythonRuntimes.length === 0) { + return undefined; + } + + let latestRuntime = pythonRuntimes[0]; + for (let idx = 1; idx < pythonRuntimes.length; idx++) { + latestRuntime = RuntimeDeterminer.latestRuntime(latestRuntime, pythonRuntimes[idx], RuntimeFamily.PYTHON); + } + + return latestRuntime; + } + + private static latestRuntime(runtime1: Runtime, runtime2: Runtime, family: RuntimeFamily) { + let sliceStart: number; + switch (family) { + case RuntimeFamily.NODEJS: { + sliceStart = 'nodejs'.length; + break; + } + case RuntimeFamily.PYTHON: { + sliceStart = 'python'.length; + break; + } + default: { + sliceStart = 0; + break; + } + } + + const version1 = runtime1.name.slice(sliceStart).split('.'); + const version2 = runtime2.name.slice(sliceStart).split('.'); + + const versionLength = Math.min(version1.length, version2.length); + for (let idx = 0; idx < versionLength; idx++) { + if (parseInt(version1[idx]) > parseInt(version2[idx])) { + return runtime1; + } + + if (parseInt(version1[idx]) < parseInt(version2[idx])) { + return runtime2; + } + } + + return runtime1; + } + + private constructor() {} +} \ No newline at end of file diff --git a/packages/aws-cdk-lib/handler-framework/test/cdk-handler.test.ts b/packages/aws-cdk-lib/handler-framework/test/cdk-handler.test.ts new file mode 100644 index 0000000000000..f3e712056a518 --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/test/cdk-handler.test.ts @@ -0,0 +1,39 @@ +import * as path from 'path'; +import { Runtime } from '../../aws-lambda'; +import { Stack } from '../../core'; +import { CdkHandler } from '../lib/cdk-handler'; + +describe('cdk handler', () => { + let codeDirectory: string; + beforeAll(() => { + codeDirectory = path.join(__dirname, 'test-handler'); + }); + + test('code directory property is correctly set', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const handler = new CdkHandler(stack, 'CdkHandler', { + codeDirectory, + compatibleRuntimes: [Runtime.NODEJS_16_X, Runtime.NODEJS_LATEST, Runtime.PYTHON_3_12], + }); + + // THEN + expect(handler.codeDirectory).toEqual(codeDirectory); + }); + + test('runtime property is correctly set', () => { + // GIVEN + const stack = new Stack(); + + // WHEN + const handler = new CdkHandler(stack, 'CdkHandler', { + codeDirectory, + compatibleRuntimes: [Runtime.NODEJS_16_X, Runtime.NODEJS_LATEST, Runtime.PYTHON_3_12], + }); + + // THEN + expect(handler.runtime.runtimeEquals(Runtime.NODEJS_LATEST)).toBe(true); + }); +}); diff --git a/packages/aws-cdk-lib/handler-framework/test/mock-provider/.gitignore b/packages/aws-cdk-lib/handler-framework/test/mock-provider/.gitignore new file mode 100644 index 0000000000000..033e6722bb6e0 --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/test/mock-provider/.gitignore @@ -0,0 +1 @@ +!index.js diff --git a/packages/aws-cdk-lib/handler-framework/test/mock-provider/index.js b/packages/aws-cdk-lib/handler-framework/test/mock-provider/index.js new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/aws-cdk-lib/handler-framework/test/test-handler/index.ts b/packages/aws-cdk-lib/handler-framework/test/test-handler/index.ts new file mode 100644 index 0000000000000..4c6a8a3937bf8 --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/test/test-handler/index.ts @@ -0,0 +1,3 @@ +export async function handler() { + return { message: 'Hello, world!' }; +} diff --git a/packages/aws-cdk-lib/handler-framework/test/utils/runtime-determiner.test.ts b/packages/aws-cdk-lib/handler-framework/test/utils/runtime-determiner.test.ts new file mode 100644 index 0000000000000..ce8fca15ebd6d --- /dev/null +++ b/packages/aws-cdk-lib/handler-framework/test/utils/runtime-determiner.test.ts @@ -0,0 +1,79 @@ +import { Runtime } from '../../../aws-lambda'; +import { RuntimeDeterminer } from '../../lib/utils/runtime-determiner'; + +const DEFAULT_RUNTIME = Runtime.NODEJS_LATEST; + +describe('latest runtime', () => { + test('selects default runtime', () => { + // GIVEN + const runtimes = [Runtime.NODEJS_16_X, Runtime.PYTHON_3_12, Runtime.NODEJS_18_X]; + + // WHEN + const latestRuntime = RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + + // THEN + expect(latestRuntime?.runtimeEquals(DEFAULT_RUNTIME)).toEqual(true); + }); + + test('selects latest nodejs runtime', () => { + // GIVEN + const runtimes = [Runtime.NODEJS_16_X, Runtime.PYTHON_3_12, Runtime.NODEJS_14_X, Runtime.PYTHON_3_11, Runtime.NODEJS_20_X]; + + // WHEN + const latestRuntime = RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + + // THEN + expect(latestRuntime?.runtimeEquals(Runtime.NODEJS_20_X)).toEqual(true); + }); + + test('selects latest python runtime', () => { + // GIVEN + const runtimes = [Runtime.PYTHON_3_10, Runtime.PYTHON_3_11, Runtime.PYTHON_3_7]; + + // WHEN + const latestRuntime = RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + + // THEN + expect(latestRuntime?.runtimeEquals(Runtime.PYTHON_3_11)).toEqual(true); + }); + + test('throws if no runtimes are specified', () => { + // GIVEN + const runtimes = []; + + // WHEN / THEN + expect(() => { + RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + }).toThrow('You must specify at least one compatible runtime'); + }); + + test('throws if latest nodejs runtime is deprecated', () => { + // GIVEN + const runtimes = [Runtime.NODEJS_12_X, Runtime.NODEJS_14_X]; + + // WHEN / THEN + expect(() => { + RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + }).toThrow(`Latest nodejs runtime ${Runtime.NODEJS_14_X} is deprecated. You must upgrade to the latest code compatible nodejs runtime`); + }); + + test('throws if latest python runtime is deprecated', () => { + // GIVEN + const runtimes = [Runtime.PYTHON_2_7, Runtime.PYTHON_3_6]; + + // WHEN / THEN + expect(() => { + RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + }).toThrow(`Latest python runtime ${Runtime.PYTHON_3_6} is deprecated. You must upgrade to the latest code compatible python runtime`); + }); + + test('throws if runtimes are neither nodejs nor python', () => { + // GIVEN + const runtimes = [Runtime.JAVA_17, Runtime.RUBY_3_2]; + + // WHEN / THEN + expect(() => { + RuntimeDeterminer.determineLatestRuntime(DEFAULT_RUNTIME, runtimes); + }).toThrow('Compatible runtimes must contain only nodejs or python runtimes'); + }); +});