diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 79e1f0b24f..a4b12d654e 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -7,6 +7,50 @@ permissions: contents: read jobs: + # Deploys one run-scoped shared LMI capacity provider stack per architecture. + # EC2-backed capacity providers are the slowest resources in the LMI e2e + # suites and are subject to account vCPU quotas, so the suites share one per + # architecture instead of provisioning their own (see + # packages/testing/src/lmi/sharedCapacityProviderStack.ts). + setup-lmi-capacity-providers: + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + NODE_ENV: dev + environment: e2e-tests + permissions: + id-token: write # needed to interact with GitHub's OIDC Token endpoint. + contents: read + steps: + - name: Checkout Repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + - name: Setup dependencies + uses: aws-powertools/actions/.github/actions/cached-node-modules@3b5b8e2e58b7af07994be982e83584a94e8c76c5 # v1.5.0 + with: + node-version: 24 + - name: Setup AWS credentials + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + role-to-assume: ${{ secrets.E2E_IAM_ROLE_ARN }} + aws-region: eu-west-1 + mask-aws-account-id: true + # The capacity provider ARNs are NOT passed to the test jobs as job + # outputs: they contain the AWS account id, which is masked, and GitHub + # silently drops job outputs containing masked values. The stack names + # are deterministic (LmiShared--), so each test job + # resolves the ARN itself from the stack outputs. + - name: Deploy shared LMI capacity providers (all architectures) + run: npm run lmi:deploy -w packages/testing + + # Runs every package's e2e suites EXCEPT the LMI ones (each package's + # `test:e2e` script excludes `tests/e2e/lmi.*`). Deliberately does NOT + # depend on the LMI setup job: these suites don't use the capacity + # provider, so they fan out immediately and its EC2-backed provisioning + # time is amortized behind them. run-e2e-tests-on-utils: runs-on: ubuntu-latest env: @@ -58,4 +102,113 @@ jobs: ARCH: ${{ matrix.arch }} JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: true RUNNER_DEBUG: ${{ env.RUNNER_DEBUG }} - run: npm run test:e2e -w ${{ matrix.package }} \ No newline at end of file + run: npm run test:e2e -w ${{ matrix.package }} + + # Runs only the LMI suites (`tests/e2e/lmi.*`, via each package's + # `test:e2e:lmi` script) for the packages that have them. Gated on the + # shared capacity provider being ready. + run-e2e-tests-lmi: + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: setup-lmi-capacity-providers + env: + NODE_ENV: dev + environment: e2e-tests + permissions: + id-token: write # needed to interact with GitHub's OIDC Token endpoint. + contents: read + strategy: + matrix: + package: [packages/logger] + version: [22, 24] + arch: [x86_64, arm64] + fail-fast: false + steps: + - name: Checkout Repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + - name: Setup dependencies + uses: aws-powertools/actions/.github/actions/cached-node-modules@3b5b8e2e58b7af07994be982e83584a94e8c76c5 # v1.5.0 + with: + node-version: 24 + - name: Setup AWS credentials + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + role-to-assume: ${{ secrets.E2E_IAM_ROLE_ARN }} + aws-region: eu-west-1 + mask-aws-account-id: true + # The setup job's stack name is deterministic for a given run id and + # architecture, so the ARN is resolved from CloudFormation instead of a + # job output (job outputs containing the masked account id are silently + # dropped by GitHub). `>> $GITHUB_ENV` is safe: masking only redacts + # logs, not environment values. + - name: Resolve shared LMI capacity provider ARN + env: + ARCH: ${{ matrix.arch }} + run: | + arn=$(aws cloudformation describe-stacks \ + --stack-name "LmiShared-${GITHUB_RUN_ID}-${ARCH//_/-}" \ + --query "Stacks[0].Outputs[?OutputKey=='CapacityProviderArn'].OutputValue" \ + --output text) + # `--output text` prints an empty string (exit 0) when the query + # matches nothing and `None` when the output is null. Either would be + # silently treated as "no shared provider", making every cell fall + # back to provisioning its own VPC + capacity provider and defeating + # the shared-provider design, so fail loudly instead. + if [[ -z "$arn" || "$arn" == "None" ]]; then + echo "::error::Could not resolve shared LMI capacity provider ARN for LmiShared-${GITHUB_RUN_ID}-${ARCH//_/-}" + exit 1 + fi + echo "LMI_CAPACITY_PROVIDER_ARN=$arn" >> "$GITHUB_ENV" + - name: Run LMI e2e ${{ matrix.package }}-${{ matrix.version }}-${{ matrix.arch }} + env: + RUNTIME: nodejs${{ matrix.version }}x + CI: true + ARCH: ${{ matrix.arch }} + JSII_SILENCE_WARNING_DEPRECATED_NODE_VERSION: true + RUNNER_DEBUG: ${{ env.RUNNER_DEBUG }} + run: npm run test:e2e:lmi -w ${{ matrix.package }} + + # Tears down the shared capacity provider stacks as soon as the LMI suites + # finish: the non-LMI matrix doesn't use them, so it doesn't hold them up. + # Retries: non-LMI job failures can be recovered with "re-run failed jobs" + # (those cells never touch the shared stacks). LMI job failures need + # "re-run all jobs": this teardown destroys the stacks at the end of each + # attempt, so a re-run LMI cell fails fast at ARN resolution ("stack does + # not exist") until the setup job re-runs and redeploys them. + teardown-lmi-capacity-providers: + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [setup-lmi-capacity-providers, run-e2e-tests-lmi] + if: always() + env: + NODE_ENV: dev + environment: e2e-tests + permissions: + id-token: write # needed to interact with GitHub's OIDC Token endpoint. + contents: read + steps: + - name: Checkout Repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24 + - name: Setup dependencies + uses: aws-powertools/actions/.github/actions/cached-node-modules@3b5b8e2e58b7af07994be982e83584a94e8c76c5 # v1.5.0 + with: + node-version: 24 + - name: Setup AWS credentials + uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + role-to-assume: ${{ secrets.E2E_IAM_ROLE_ARN }} + aws-region: eu-west-1 + mask-aws-account-id: true + # The script destroys all architectures' stacks concurrently and + # attempts every teardown even if one fails; destroying a non-existent + # stack is a no-op + - name: Destroy shared LMI capacity providers (all architectures) + run: npm run lmi:destroy -w packages/testing diff --git a/package-lock.json b/package-lock.json index 900b1fb320..47d622d6eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8637,7 +8637,10 @@ "@aws/lambda-invoke-store": "0.3.0" }, "devDependencies": { - "@aws-lambda-powertools/testing-utils": "file:../testing" + "@aws-lambda-powertools/testing-utils": "file:../testing", + "@aws-sdk/client-cloudwatch-logs": "^3.1079.0", + "@types/promise-retry": "^1.1.3", + "promise-retry": "^2.0.1" }, "peerDependencies": { "@aws-lambda-powertools/jmespath": "2.34.0", @@ -8764,6 +8767,7 @@ "license": "MIT-0", "dependencies": { "@aws-cdk/toolkit-lib": "^1.35.0", + "@aws-sdk/client-cloudformation": "^3.1101.0", "@aws-sdk/client-cloudwatch-logs": "^3.1101.0", "@aws-sdk/client-lambda": "^3.1101.0", "@aws/lambda-invoke-store": "0.3.0", diff --git a/packages/logger/package.json b/packages/logger/package.json index 4c70a5b369..ac6e3bbd7d 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -15,9 +15,10 @@ "test:unit:coverage": "vitest --run tests/unit --coverage.enabled --coverage.thresholds.100 --coverage.include='src/**'", "test:unit:types": "echo 'Not Implemented'", "test:unit:watch": "vitest tests/unit", - "test:e2e:nodejs22x": "RUNTIME=nodejs22x vitest --run tests/e2e", - "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run tests/e2e", - "test:e2e": "vitest --run tests/e2e", + "test:e2e:nodejs22x": "RUNTIME=nodejs22x vitest --run tests/e2e --exclude '**/tests/e2e/lmi.*'", + "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run tests/e2e --exclude '**/tests/e2e/lmi.*'", + "test:e2e": "vitest --run tests/e2e --exclude '**/tests/e2e/lmi.*'", + "test:e2e:lmi": "vitest --run tests/e2e/lmi.", "build:cjs": "tsc --build tsconfig.cjs.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", "build:esm": "tsc --build tsconfig.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", "build:tests": "tsc --noEmit -p tests/tsconfig.json", @@ -73,7 +74,10 @@ "types": "./lib/cjs/index.d.ts", "main": "./lib/cjs/index.js", "devDependencies": { - "@aws-lambda-powertools/testing-utils": "file:../testing" + "@aws-lambda-powertools/testing-utils": "file:../testing", + "@aws-sdk/client-lambda": "^3.1079.0", + "@types/promise-retry": "^1.1.3", + "promise-retry": "^2.0.1" }, "peerDependencies": { "@aws-lambda-powertools/jmespath": "2.34.0", diff --git a/packages/logger/tests/e2e/lmi.test.FunctionCode.ts b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts new file mode 100644 index 0000000000..4142e12dbc --- /dev/null +++ b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts @@ -0,0 +1,79 @@ +import { randomUUID } from 'node:crypto'; +import { setTimeout } from 'node:timers/promises'; +import { Logger } from '@aws-lambda-powertools/logger'; +import type { Context } from 'aws-lambda'; + +// Module scope: identifies the execution environment across invocations +const executionEnvId = randomUUID(); + +// Capture the log lines the Logger emits so they can be returned in the +// response payload: on LMI the Invoke API does not support Tail logs and +// CloudWatch delivery is asynchronous, so returning the logs is the only +// fully deterministic way for the test to read them. In production mode the +// Logger writes each log line as a single atomic write to process.stdout +// (via its own Console instance, bypassing Lambda's patched global console), +// so intercepting the stream captures the real production write path. +const capturedLogs: Array> = []; +const originalWrite = process.stdout.write.bind(process.stdout); +process.stdout.write = ((chunk: string | Uint8Array, ...rest: unknown[]) => { + try { + capturedLogs.push(JSON.parse(chunk.toString())); + } catch { + // not a JSON log line, ignore + } + // @ts-expect-error - passing through the remaining overloaded args as-is + return originalWrite(chunk, ...rest); +}) as typeof process.stdout.write; + +const logger = new Logger(); + +// Invocations multiplexed into the same execution environment share this +// module-scoped state, which lets us prove a genuine overlap: every +// invocation blocks until a second invocation is in flight in the same +// environment (or times out reporting that it stayed alone) +let inFlight = 0; +let barrier = Promise.withResolvers(); + +export const handler = async ( + event: { invocationId: string; role: 'warmup' | 'test' }, + context: Context +) => { + logger.addContext(context); + logger.appendKeys({ invocationKey: event.invocationId }); + + let sawPeer = false; + if (event.role === 'test') { + inFlight++; + if (inFlight >= 2) { + barrier.resolve(); + } + sawPeer = await Promise.race([ + barrier.promise.then(() => true), + setTimeout(15_000, false), + ]); + inFlight--; + if (inFlight === 0) { + barrier = Promise.withResolvers(); + } + } + + logger.info('LMI isolation test'); + logger.resetKeys(); + + return { + invocationId: event.invocationId, + executionEnvId, + sawPeer, + initializationType: process.env.AWS_LAMBDA_INITIALIZATION_TYPE ?? 'unset', + maxConcurrency: process.env.AWS_LAMBDA_MAX_CONCURRENCY ?? 'unset', + // Only the lines this invocation emitted, selected by the request id + // stamped on them. Under LMI multiplexing this only works because + // addContext scopes the lambda context per invocation via the + // InvokeStore (#5430) — an empty logs array here is the signature of + // that scoping regressing. The invocationKey assertion in the test + // then verifies appendKeys isolation on independently-selected lines. + logs: capturedLogs.filter( + (log) => log.function_request_id === context.awsRequestId + ), + }; +}; diff --git a/packages/logger/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts new file mode 100644 index 0000000000..ec0b0ece6f --- /dev/null +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -0,0 +1,229 @@ +import { Console } from 'node:console'; +import { join } from 'node:path'; +import { TestStack } from '@aws-lambda-powertools/testing-utils'; +import { lmiFunctionStackTestName } from '@aws-lambda-powertools/testing-utils/lmi'; +import { TestLmiCapacityProvider } from '@aws-lambda-powertools/testing-utils/resources/capacity-provider'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { Tracing } from 'aws-cdk-lib/aws-lambda'; +import promiseRetry from 'promise-retry'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { LoggerTestNodejsFunction } from '../helpers/resources.js'; +import { RESOURCE_NAME_PREFIX } from './constants.js'; + +type IsolationResult = { + invocationId: string; + executionEnvId: string; + sawPeer: boolean; + initializationType: string; + maxConcurrency: string; + logs: Array<{ + message: string; + invocationKey?: string; + function_request_id?: string; + }>; +}; + +/** + * In this e2e test for Logger, we test the InvokeStore-backed isolation of log + * attributes on Lambda Managed Instances (LMI), where multiple invocations run + * concurrently within the same execution environment. + * + * The function is associated with a small (12 vCPU) capacity provider. The + * LMI scheduler prefers scaling out to fresh execution environments over + * multiplexing invocations into busy ones, so forcing a genuine overlap does + * not depend on precisely sizing the fleet: instead the handler blocks on a + * module-scoped promise barrier (see `lmi.test.FunctionCode.ts`) until a peer + * invocation lands in the same environment. Holding every invocation open at + * once keeps environments busy long enough that the scheduler multiplexes at + * least one pair together, which is all the assertion needs. Without + * InvokeStore isolation, the overlapping invocations' appended keys would + * bleed into each other's log output. + * + * Exact environment counts are not asserted and vary with fleet size and load + * — in CI both Node.js versions share one per-architecture provider, so a run + * may spread these invocations across a couple of dozen environments and still + * multiplex a handful; a local run against an ephemeral provider looks + * different again. The barrier is what guarantees an overlap regardless. + * + * The Invoke API does not support Tail logs for capacity provider functions + * and CloudWatch log delivery is asynchronous, so the handler intercepts its + * own process.stdout stream to capture the log lines the Logger emits and + * returns them in the response payload, making log collection fully + * deterministic while exercising the production log write path. + */ +// Same pattern as TestStack's ioHost: a dedicated Console writing straight to +// the process streams bypasses vitest's output capture, so these phase +// markers appear in real time. The invocation phase takes minutes with no +// other output, and when it fails these markers are the only way to tell +// which phase died. +const testConsole = new Console({ + stdout: process.stdout, + stderr: process.stderr, +}); + +describe('Logger E2E - Lambda Managed Instances', () => { + // Fire enough concurrent invocations, all held open on the barrier, that + // the scheduler multiplexes at least one pair into a shared execution + // environment rather than giving every invocation its own. The count only + // needs to comfortably exceed the fleet's environment count; the barrier, + // not a precise number, is what forces the overlap. + const invocationCount = 30; + + // The test name embeds an `Lmi` marker and the workflow run id + // (`Lmi-`) so the teardown job can find and delete this stack if the + // cell is cancelled or times out before its own `afterAll` runs, leaving + // the function attached to the shared capacity provider. + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: lmiFunctionStackTestName(), + }, + }); + + // Location of the lambda function code + const lambdaFunctionCodeFilePath = join( + __dirname, + 'lmi.test.FunctionCode.ts' + ); + + // In CI a setup job deploys one shared capacity provider per architecture + // (see lmi/deploySharedCapacityProvider.ts in the testing package) and + // passes its ARN via the environment; otherwise (e.g. local runs) fall back + // to an ephemeral capacity provider that lives and dies with this suite's + // stack. The env var is treated as unset when empty so that a + // mis-referenced workflow output degrades to the fallback instead of an + // invalid ARN. + const sharedCapacityProviderArn = + process.env.LMI_CAPACITY_PROVIDER_ARN?.trim(); + const capacityProvider = sharedCapacityProviderArn + ? sharedCapacityProviderArn + : new TestLmiCapacityProvider(testStack); + new LoggerTestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + // ACTIVE tracing compatibility with LMI is unverified + tracing: Tracing.DISABLED, + }, + { + nameSuffix: 'LmiIsolation', + lmi: { + capacityProvider, + perExecutionEnvironmentMaxConcurrency: 10, + }, + } + ); + + const lambdaClient = new LambdaClient({}); + let functionName: string; + + const invokeOnce = async (payload: { + invocationId: string; + role: 'warmup' | 'test'; + }): Promise => { + const response = await lambdaClient.send( + new InvokeCommand({ + FunctionName: functionName, + InvocationType: 'RequestResponse', + Payload: JSON.stringify(payload), + }) + ); + if (response.FunctionError) { + throw new Error( + `Invocation ${payload.invocationId} failed: ${response.FunctionError}` + ); + } + return JSON.parse( + Buffer.from(response.Payload ?? new Uint8Array()).toString() + ); + }; + + let results: IsolationResult[]; + + beforeAll(async () => { + await testStack.deploy(); + + functionName = testStack.findAndGetStackOutputValue('LmiIsolation'); + testConsole.log( + `[lmi] stack deployed (${sharedCapacityProviderArn ? 'shared' : 'ephemeral'} capacity provider), warming up ${functionName}...` + ); + + // The first invocation on a fresh capacity provider may have to wait + // for an EC2 instance to boot, so retry until capacity is available + await promiseRetry( + async (retry, attempt) => { + await invokeOnce({ invocationId: 'warmup', role: 'warmup' }).catch( + (error) => { + testConsole.log( + `[lmi] warmup attempt ${attempt} failed, retrying...` + ); + retry(error); + } + ); + }, + { + retries: 10, + factor: 2, + minTimeout: 5_000, + maxTimeout: 60_000, + } + ); + testConsole.log( + `[lmi] warmup complete, firing ${invocationCount} concurrent invocations...` + ); + + // Every invocation blocks inside the handler until a second invocation + // lands in the same execution environment. Dispatching all of them + // simultaneously saturates the fleet, which forces the scheduler to + // multiplex the overflow into busy environments + results = await Promise.all( + Array.from({ length: invocationCount }, (_, index) => + invokeOnce({ invocationId: `inv-${index}`, role: 'test' }) + ) + ); + + const multiplexed = results.filter((result) => result.sawPeer).length; + const environments = new Set( + results.map((result) => result.executionEnvId) + ); + testConsole.log( + `[lmi] ${results.length}/${invocationCount} responses; ${multiplexed} multiplexed across ${environments.size} execution environments` + ); + }, 1_200_000); // VPC + capacity provider + instance boot can exceed the default hook timeout + + it('isolates log attributes across concurrent invocations in the same execution environment', () => { + expect(results).toHaveLength(invocationCount); + + // The function actually ran on Lambda Managed Instances with the + // concurrency path active: AWS_LAMBDA_MAX_CONCURRENCY drives + // shouldUseInvokeStore() in @aws-lambda-powertools/commons + for (const result of results) { + expect(result.initializationType).toBe('lambda-managed-instances'); + expect(result.maxConcurrency).toBe('10'); + } + + // At least one pair of invocations genuinely overlapped inside the + // same execution environment: they observed each other through the + // module-scoped barrier. The scheduler may still scale out some of + // the invocations to other environments; that's fine as long as a + // real overlap happened somewhere. + expect(results.some((result) => result.sawPeer === true)).toBe(true); + + // Each invocation's captured logs carry exactly its own key: without + // isolation, concurrent appendKeys calls within a shared environment + // would bleed across the invocations blocked on the barrier + for (const result of results) { + const isolationLogs = result.logs.filter( + (log) => log.message === 'LMI isolation test' + ); + expect(isolationLogs).toHaveLength(1); + expect(isolationLogs[0].invocationKey).toBe(result.invocationId); + } + }); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await testStack.destroy(); + } + }, 1_200_000); +}); diff --git a/packages/testing/package.json b/packages/testing/package.json index 912a022ac1..12c1ddffce 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -11,6 +11,8 @@ "test": "vitest --run", "test:unit": "vitest --run", "test:e2e": "echo 'Not implemented'", + "lmi:deploy": "node lib/esm/lmi/deploySharedCapacityProvider.js", + "lmi:destroy": "node lib/esm/lmi/destroySharedCapacityProvider.js", "build:cjs": "tsc --build tsconfig.cjs.json && echo '{ \"type\": \"commonjs\" }' > lib/cjs/package.json", "build:esm": "tsc --build tsconfig.json && echo '{ \"type\": \"module\" }' > lib/esm/package.json", "build:tests": "tsc --noEmit -p tests/tsconfig.json", @@ -47,6 +49,14 @@ "import": "./lib/esm/resources/TestDynamodbTable.js", "require": "./lib/cjs/resources/TestDynamodbTable.js" }, + "./resources/capacity-provider": { + "import": "./lib/esm/resources/TestLmiCapacityProvider.js", + "require": "./lib/cjs/resources/TestLmiCapacityProvider.js" + }, + "./lmi": { + "import": "./lib/esm/lmi/naming.js", + "require": "./lib/cjs/lmi/naming.js" + }, "./context": { "import": "./lib/esm/context.js", "require": "./lib/cjs/context.js" @@ -70,6 +80,14 @@ "lib/cjs/resources/TestDynamodbTable.d.ts", "lib/esm/resources/TestDynamodbTable.d.ts" ], + "resources/capacity-provider": [ + "lib/cjs/resources/TestLmiCapacityProvider.d.ts", + "lib/esm/resources/TestLmiCapacityProvider.d.ts" + ], + "lmi": [ + "lib/cjs/lmi/naming.d.ts", + "lib/esm/lmi/naming.d.ts" + ], "types": [ "lib/cjs/types.d.ts", "lib/esm/types.d.ts" @@ -100,6 +118,7 @@ "homepage": "https://github.com/aws-powertools/powertools-lambda-typescript/tree/main/packages/testing#readme", "dependencies": { "@aws-cdk/toolkit-lib": "^1.35.0", + "@aws-sdk/client-cloudformation": "^3.1101.0", "@aws-sdk/client-cloudwatch-logs": "^3.1101.0", "@aws-sdk/client-lambda": "^3.1101.0", "@aws/lambda-invoke-store": "0.3.0", diff --git a/packages/testing/src/TestStack.ts b/packages/testing/src/TestStack.ts index 49253d96b4..b362b79070 100644 --- a/packages/testing/src/TestStack.ts +++ b/packages/testing/src/TestStack.ts @@ -102,7 +102,7 @@ class TestStack { return; } if (msg.message.includes('✅') && !msg.message.includes('deployed')) { - testConsole.log(`${that.testName} deployed successfully`); + testConsole.log(`${that.stack.stackName} deployed successfully`); return; } if (msg.message.includes('CREATE_IN_PROGRESS')) { @@ -110,7 +110,9 @@ class TestStack { return; } lastCreateLog = Date.now(); - testConsole.log(`${that.testName} stack is being created...`); + testConsole.log( + `${that.stack.stackName} stack is being created...` + ); return; } if (msg.message.includes('DELETE_IN_PROGRESS')) { @@ -118,7 +120,9 @@ class TestStack { return; } lastDestroyLog = Date.now(); - testConsole.log(`${that.testName} stack is being destroyed...`); + testConsole.log( + `${that.stack.stackName} stack is being destroyed...` + ); return; } if (['warning', 'error'].includes(msg.level)) { @@ -138,23 +142,38 @@ class TestStack { }); } + /** + * Directory where the Cloud Assembly for this stack is synthesized. + */ + #outdir(): string { + return join(tmpdir(), `${this.stack.stackName}-powertools-e2e-testing`); + } + + /** + * Synthesize the CDK app into a Cloud Assembly. + * + * `clobberEnv: false` keeps the toolkit from temporarily replacing the + * global `process.env` with an immutable proxy during synthesis. That swap + * is not concurrency-safe, so without this two `TestStack` instances + * synthesizing at once (e.g. the shared capacity provider stacks, one per + * architecture) would race on `process.env`. The assembly builder does not + * read the injected env, so opting out is safe here. + */ + async #synthAssembly(): Promise { + return await this.#cli.fromAssemblyBuilder(async () => this.app.synth(), { + outdir: this.#outdir(), + clobberEnv: false, + }); + } + /** * Deploy the test stack to the selected environment. * * It returns the outputs of the deployed stack. */ public async deploy(): Promise> { - const outdir = join( - tmpdir(), - `${this.stack.stackName}-powertools-e2e-testing` - ); - const outputFilePath = join(outdir, 'outputs.json'); - this.#cx = await this.#cli.fromAssemblyBuilder( - async () => this.app.synth(), - { - outdir, - } - ); + const outputFilePath = join(this.#outdir(), 'outputs.json'); + this.#cx = await this.#synthAssembly(); await this.#cli.deploy(this.#cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS, @@ -172,11 +191,14 @@ class TestStack { /** * Destroy the test stack. + * + * The Cloud Assembly is normally created by {@link deploy | `deploy()`}; + * when destroy is called in a fresh process (e.g. a workflow teardown job + * destroying a stack deployed by an earlier setup job), it is re-synthesized + * from the app, which must therefore define the same stack. */ public async destroy(): Promise { - if (!this.#cx) { - throw new Error('Cannot destroy stack without a Cloud Assembly'); - } + this.#cx ??= await this.#synthAssembly(); await this.#cli.destroy(this.#cx, { stacks: { strategy: StackSelectionStrategy.ALL_STACKS, diff --git a/packages/testing/src/invokeTestFunction.ts b/packages/testing/src/invokeTestFunction.ts index 8ebdde714b..8a3653afa6 100644 --- a/packages/testing/src/invokeTestFunction.ts +++ b/packages/testing/src/invokeTestFunction.ts @@ -6,7 +6,13 @@ import type { InvokeTestFunctionOptions } from './types.js'; const lambdaClient = new LambdaClient({}); /** - * Invoke a Lambda function once and return the logs + * Invoke a Lambda function once and return the logs. + * + * `LogType: 'Tail'` is not supported on functions configured with a Lambda + * Managed Instances capacity provider — the invoke is rejected — so this + * helper cannot be used to invoke such functions. LMI suites invoke their + * functions directly and collect logs by other means (e.g. from the response + * payload) instead. */ const invokeFunctionOnce = async ({ functionName, @@ -19,7 +25,8 @@ const invokeFunctionOnce = async ({ new InvokeCommand({ FunctionName: functionName, InvocationType: 'RequestResponse', - LogType: 'Tail', // Wait until execution completes and return all logs + // Wait until execution completes and return all logs + LogType: 'Tail', Payload: fromUtf8(JSON.stringify(payload)), }) ); @@ -64,7 +71,10 @@ const invokeFunction = async ({ ? payload[index] : payload; - return invoke({ functionName, payload: invocationPayload }); + return invoke({ + functionName, + payload: invocationPayload, + }); }) )) ); @@ -74,7 +84,10 @@ const invokeFunction = async ({ ? payload[index] : payload; invocationLogs.push( - await invokeFunctionOnce({ functionName, payload: invocationPayload }) + await invokeFunctionOnce({ + functionName, + payload: invocationPayload, + }) ); } } diff --git a/packages/testing/src/lmi/deploySharedCapacityProvider.ts b/packages/testing/src/lmi/deploySharedCapacityProvider.ts new file mode 100644 index 0000000000..50969f900f --- /dev/null +++ b/packages/testing/src/lmi/deploySharedCapacityProvider.ts @@ -0,0 +1,34 @@ +import { TEST_ARCHITECTURES } from '../constants.js'; +import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack.js'; + +/** + * Deploy the run-scoped shared LMI capacity provider stacks, one per + * architecture, concurrently: the stacks are independent and EC2-backed, so + * deploying them sequentially would roughly double the setup time. + * + * Intended to run as a workflow setup job step (after the packages have been + * built): + * ```yaml + * - run: npm run lmi:deploy -w packages/testing + * ``` + * The ARNs are deliberately not exposed as job outputs: they contain the AWS + * account id, which CI masks, and GitHub silently drops job outputs that + * contain masked values. The stack names are deterministic, so test jobs + * resolve the ARN from the stack outputs instead (see the e2e workflow). + */ +const main = async (): Promise => { + await Promise.all( + (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) + .map((architecture) => buildSharedCapacityProviderStack(architecture)) + .map(async (testStack) => { + await testStack.deploy(); + const arn = testStack.findAndGetStackOutputValue('CapacityProviderArn'); + console.log(`${testStack.stack.stackName}: ${arn}`); + }) + ); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/testing/src/lmi/destroySharedCapacityProvider.ts b/packages/testing/src/lmi/destroySharedCapacityProvider.ts new file mode 100644 index 0000000000..fd7b573ced --- /dev/null +++ b/packages/testing/src/lmi/destroySharedCapacityProvider.ts @@ -0,0 +1,68 @@ +import { TEST_ARCHITECTURES } from '../constants.js'; +import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack.js'; +import { sweepOrphanedFunctionStacks } from './sweepOrphanedFunctionStacks.js'; + +/** + * Destroy the run-scoped shared LMI capacity provider stacks, one per + * architecture, concurrently. + * + * Intended to run as a workflow teardown job step (with `if: always()` so the + * stacks are removed even when the test jobs fail): + * ```yaml + * - run: npm run lmi:destroy -w packages/testing + * ``` + * The stack names are deterministic for a given run id and architecture, so + * the teardown job reconstructs the same stacks the setup job deployed. Each + * destroy failure is reported but does not prevent the other architecture's + * teardown from being attempted. + */ +const destroyProviderStacks = async (): Promise => { + const results = await Promise.allSettled( + (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) + .map((architecture) => buildSharedCapacityProviderStack(architecture)) + .map(async (testStack) => { + await testStack.destroy(); + console.log(`Destroyed ${testStack.stack.stackName}`); + }) + ); + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected' + ); + if (rejected.length > 0) { + for (const failure of rejected) { + console.error(failure.reason); + } + throw new AggregateError( + rejected.map((failure) => failure.reason), + 'One or more shared capacity provider stacks failed to delete' + ); + } +}; + +const main = async (): Promise => { + // A function attached to a capacity provider by ARN keeps the provider's + // ENIs in use, and that cross-stack relationship is invisible to + // CloudFormation, so a function stack orphaned by a cancelled or timed-out + // cell would make the provider-stack delete fail. Sweep any such leftover + // function stacks first; on a healthy run this is a no-op because each + // suite's own `afterAll` already deleted its stack. + const swept = await sweepOrphanedFunctionStacks(); + if (swept.length > 0) { + console.log(`Swept orphaned LMI function stacks: ${swept.join(', ')}`); + } + + try { + await destroyProviderStacks(); + } catch (error) { + // A first failure is most often a transient throttle or an ENI still + // detaching from a just-swept function stack; one retry clears both. + console.error('Retrying shared capacity provider teardown after error:'); + console.error(error); + await destroyProviderStacks(); + } +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/testing/src/lmi/naming.ts b/packages/testing/src/lmi/naming.ts new file mode 100644 index 0000000000..f062332f8c --- /dev/null +++ b/packages/testing/src/lmi/naming.ts @@ -0,0 +1,38 @@ +/** + * Resolve the workflow run id used to scope Lambda Managed Instances (LMI) + * e2e stack names. + * + * The run id is embedded in both the shared capacity provider stack names + * (`LmiShared--`) and the per-suite function stack names (via + * {@link lmiFunctionStackTestName}), so the teardown job can find and delete + * every LMI stack belonging to a run — including function stacks orphaned by + * a cell that was cancelled or timed out before its own teardown. + * + * It is restricted to the characters CloudFormation allows in a stack name + * (alphanumerics and hyphens); `GITHUB_RUN_ID` is numeric and the local + * default is safe. + */ +const getRunId = (): string => { + const runId = process.env.GITHUB_RUN_ID ?? 'local'; + if (!/^[A-Za-z0-9-]+$/.test(runId)) { + throw new Error( + `Invalid run id "${runId}": only alphanumerics and hyphens are allowed` + ); + } + return runId; +}; + +/** + * The `testName` for a per-suite LMI function stack: an `Lmi` marker joined to + * the run id. `generateTestUniqueName` appends it last, so the resulting stack + * name ends with `-Lmi-`. + * + * This same string is the token the teardown sweep matches on: it lists stacks + * and deletes those whose name contains it, catching function stacks orphaned + * by a cell that never ran its own teardown. It does not match the shared + * provider stacks (`LmiShared--...`) — `Lmi-` is not a substring of + * `LmiShared-` — which teardown deletes explicitly by name afterwards. + */ +const lmiFunctionStackTestName = (): string => `Lmi-${getRunId()}`; + +export { getRunId, lmiFunctionStackTestName }; diff --git a/packages/testing/src/lmi/sharedCapacityProviderStack.ts b/packages/testing/src/lmi/sharedCapacityProviderStack.ts new file mode 100644 index 0000000000..213b0b0de4 --- /dev/null +++ b/packages/testing/src/lmi/sharedCapacityProviderStack.ts @@ -0,0 +1,56 @@ +import { App, CfnOutput, Stack } from 'aws-cdk-lib'; +import type { TEST_ARCHITECTURES } from '../constants.js'; +import { TestLmiCapacityProvider } from '../resources/TestLmiCapacityProvider.js'; +import { TestStack } from '../TestStack.js'; +import { getRunId } from './naming.js'; + +/** + * Build the run-scoped shared Lambda Managed Instances (LMI) capacity + * provider stack. + * + * EC2-backed capacity providers and their networking are the slowest + * resources in the LMI e2e suites, so instead of every suite provisioning its + * own, a workflow run deploys ONE shared stack per architecture up front (see + * `deploySharedCapacityProvider.ts`) and passes the capacity provider ARN to + * the test cells via the `LMI_CAPACITY_PROVIDER_ARN` environment variable. + * The capacity provider is architecture-constrained but package- and + * runtime-agnostic: all packages' LMI suites, on both Node.js versions, + * attach their functions to the same per-architecture capacity provider. + * + * The stack name is scoped to the workflow run (`LmiShared--`) + * so concurrent runs never share state and a run's teardown can never race + * another run. The name must be deterministic — the teardown job reconstructs + * it in a fresh process — so it deliberately does not use + * `generateTestUniqueName()`, which embeds a random component. + */ +const buildSharedCapacityProviderStack = ( + architecture: keyof typeof TEST_ARCHITECTURES +): TestStack => { + const stackName = `LmiShared-${getRunId()}-${architecture.replace('_', '-')}`; + + const app = new App(); + const stack = new Stack(app, stackName, { + tags: { + Service: 'Powertools-for-AWS-e2e-tests', + }, + }); + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: 'LmiShared', + // Distinguishes the two architectures' otherwise-identical progress + // logs when both stacks deploy concurrently in one process; the actual + // stack name is the deterministic one passed via `stack` below + testName: architecture, + }, + app, + stack, + }); + const capacityProvider = new TestLmiCapacityProvider(testStack, architecture); + new CfnOutput(stack, 'CapacityProviderArn', { + value: capacityProvider.capacityProviderArn, + }); + + return testStack; +}; + +export { buildSharedCapacityProviderStack }; diff --git a/packages/testing/src/lmi/sweepOrphanedFunctionStacks.ts b/packages/testing/src/lmi/sweepOrphanedFunctionStacks.ts new file mode 100644 index 0000000000..d68035554c --- /dev/null +++ b/packages/testing/src/lmi/sweepOrphanedFunctionStacks.ts @@ -0,0 +1,83 @@ +import { + CloudFormationClient, + DeleteStackCommand, + ListStacksCommand, + type StackSummary, + waitUntilStackDeleteComplete, +} from '@aws-sdk/client-cloudformation'; +import { lmiFunctionStackTestName } from './naming.js'; + +/** + * Stack statuses that represent a live (or half-deleted) stack still holding + * resources. `DELETE_COMPLETE` stacks are excluded from the listing so the + * sweep only ever acts on stacks that still exist. + */ +const ACTIVE_STACK_STATUS_FILTER = [ + 'CREATE_IN_PROGRESS', + 'CREATE_FAILED', + 'CREATE_COMPLETE', + 'ROLLBACK_IN_PROGRESS', + 'ROLLBACK_FAILED', + 'ROLLBACK_COMPLETE', + 'DELETE_FAILED', + 'UPDATE_IN_PROGRESS', + 'UPDATE_COMPLETE', + 'UPDATE_ROLLBACK_IN_PROGRESS', + 'UPDATE_ROLLBACK_FAILED', + 'UPDATE_ROLLBACK_COMPLETE', +] as const; + +/** + * Delete any per-suite LMI function stacks left over from this workflow run. + * + * Each LMI suite deploys a function stack whose function is attached to the + * shared capacity provider by ARN. That relationship crosses stacks and is + * invisible to CloudFormation, so a function stack orphaned by a cancelled or + * timed-out cell keeps the capacity provider's ENIs in use and makes the + * subsequent provider-stack delete fail with `DELETE_FAILED`. + * + * The teardown job therefore sweeps these function stacks (identified by the + * run-scoped `Lmi-` marker in their name) and waits for them to be + * gone before deleting the provider stacks. On a healthy run this is a no-op: + * each suite's own `afterAll` has already deleted its stack. + * + * Returns the names of the stacks it deleted. + */ +const sweepOrphanedFunctionStacks = async ( + client: CloudFormationClient = new CloudFormationClient({}) +): Promise => { + const nameToken = lmiFunctionStackTestName(); + + const orphaned: StackSummary[] = []; + let nextToken: string | undefined; + do { + const page = await client.send( + new ListStacksCommand({ + StackStatusFilter: [...ACTIVE_STACK_STATUS_FILTER], + NextToken: nextToken, + }) + ); + for (const summary of page.StackSummaries ?? []) { + if (summary.StackName?.includes(nameToken)) { + orphaned.push(summary); + } + } + nextToken = page.NextToken; + } while (nextToken); + + const deleted = await Promise.all( + orphaned.map(async (stack) => { + const stackName = stack.StackName as string; + await client.send(new DeleteStackCommand({ StackName: stackName })); + await waitUntilStackDeleteComplete( + { client, maxWaitTime: 600 }, + { StackName: stack.StackId ?? stackName } + ); + return stackName; + }) + ); + + return deleted; +}; + +export { sweepOrphanedFunctionStacks }; diff --git a/packages/testing/src/resources/TestLmiCapacityProvider.ts b/packages/testing/src/resources/TestLmiCapacityProvider.ts new file mode 100644 index 0000000000..a25ff51d59 --- /dev/null +++ b/packages/testing/src/resources/TestLmiCapacityProvider.ts @@ -0,0 +1,85 @@ +import { randomUUID } from 'node:crypto'; +import { + InterfaceVpcEndpointAwsService, + IpProtocol, + SecurityGroup, + SubnetType, + Vpc, +} from 'aws-cdk-lib/aws-ec2'; +import { CapacityProvider } from 'aws-cdk-lib/aws-lambda'; +import { TEST_ARCHITECTURES } from '../constants.js'; +import { getArchitectureKey } from '../helpers.js'; +import type { TestStack } from '../TestStack.js'; + +/** + * A Lambda Managed Instances (LMI) capacity provider that can be used in tests. + * + * It provisions the networking (VPC + security group) required by the capacity + * provider to launch EC2 instances, constrained to the architecture under test. + * The VPC is dual-stack and outbound connectivity is provided over IPv6 via an + * egress-only internet gateway, avoiding NAT gateways entirely: they are slow + * to provision/delete and subject to a low default account quota. + * + * The capacity provider is created in the same stack as the test functions so + * it is ephemeral: it exists only for the duration of the test run and is torn + * down with the rest of the stack. EC2-backed capacity is slow to provision, + * so create one per test suite and share it across the functions in that suite. + */ +class TestLmiCapacityProvider extends CapacityProvider { + /** + * @param stack - The test stack to create the capacity provider in + * @param architecture - The architecture the capacity provider serves; + * defaults to the ambient `ARCH` environment variable, which is the right + * source inside a test suite but must be passed explicitly when a single + * process builds providers for several architectures (e.g. the shared + * capacity provider scripts in `lmi/`) + */ + public constructor( + stack: Pick, + architecture: keyof typeof TEST_ARCHITECTURES = getArchitectureKey() + ) { + const resourceId = randomUUID().substring(0, 5); + const vpc = new Vpc(stack.stack, `vpc-${resourceId}`, { + ipProtocol: IpProtocol.DUAL_STACK, + // A single AZ keeps the fleet as concentrated as possible so that + // saturating it forces concurrent invocations to be multiplexed into + // shared execution environments + maxAzs: 1, + natGateways: 0, + subnetConfiguration: [ + { + name: 'public', + subnetType: SubnetType.PUBLIC, + }, + { + // With a dual-stack VPC and no NAT gateways, egress from these + // subnets is IPv6-only via an egress-only internet gateway + name: 'private', + subnetType: SubnetType.PRIVATE_WITH_EGRESS, + }, + ], + }); + // The LMI runtime delivers telemetry to CloudWatch Logs through the + // customer VPC, and the CloudWatch Logs endpoint is not reachable over + // the VPC's IPv6-only egress path, so give it an interface endpoint + vpc.addInterfaceEndpoint(`logs-${resourceId}`, { + service: InterfaceVpcEndpointAwsService.CLOUDWATCH_LOGS, + }); + const securityGroup = new SecurityGroup(stack.stack, `sg-${resourceId}`, { + vpc, + allowAllOutbound: true, + allowAllIpv6Outbound: true, + }); + + super(stack.stack, `cp-${resourceId}`, { + subnets: vpc.privateSubnets, + securityGroups: [securityGroup], + architectures: [TEST_ARCHITECTURES[architecture]], + // The service minimum; keeps the fleet as small as possible so that + // concurrent invocations share execution environments + maxVCpuCount: 12, + }); + } +} + +export { TestLmiCapacityProvider }; diff --git a/packages/testing/src/resources/TestNodejsFunction.ts b/packages/testing/src/resources/TestNodejsFunction.ts index aae1629e45..a24897e876 100644 --- a/packages/testing/src/resources/TestNodejsFunction.ts +++ b/packages/testing/src/resources/TestNodejsFunction.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'node:crypto'; import { CfnOutput, Duration } from 'aws-cdk-lib'; -import { Alias, Tracing } from 'aws-cdk-lib/aws-lambda'; +import { Alias, type CfnFunction, Tracing } from 'aws-cdk-lib/aws-lambda'; import { NodejsFunction, OutputFormat } from 'aws-cdk-lib/aws-lambda-nodejs'; import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; import { TEST_ARCHITECTURES, TEST_RUNTIMES } from '../constants.js'; @@ -25,6 +25,9 @@ class TestNodejsFunction extends NodejsFunction { ) { const isESM = extraProps.outputFormat === 'ESM'; const { shouldPolyfillRequire = false } = extraProps; + if (extraProps.lmi && extraProps.createAlias) { + throw new Error('lmi and createAlias are mutually exclusive'); + } const { bundling, ...restProps } = props; const functionName = concatenateResourceName({ testName: stack.testName, @@ -38,7 +41,8 @@ class TestNodejsFunction extends NodejsFunction { }); super(stack.stack, `fn-${resourceId}`, { timeout: Duration.seconds(30), - memorySize: 512, + // Lambda Managed Instance functions require at least 2048 MB + memorySize: extraProps.lmi ? 2048 : 512, tracing: Tracing.ACTIVE, bundling: { ...bundling, @@ -58,6 +62,11 @@ class TestNodejsFunction extends NodejsFunction { }); let outputValue = this.functionName; + if (extraProps.lmi) { + this.#attachToCapacityProvider(extraProps.lmi); + // LMI serves the $LATEST.PUBLISHED version, so invocations must target it + outputValue = `${this.functionName}:$LATEST.PUBLISHED`; + } if (extraProps.createAlias) { const dev = new Alias(this, 'dev', { aliasName: 'dev', @@ -71,6 +80,76 @@ class TestNodejsFunction extends NodejsFunction { value: outputValue, }); } + + /** + * Associate this function with a Lambda Managed Instances capacity provider, + * given either an in-stack construct or the ARN of one in another stack. + */ + #attachToCapacityProvider(lmi: NonNullable): void { + const { capacityProvider, ...scaling } = lmi; + if (typeof capacityProvider === 'string') { + this.#attachToCapacityProviderArn(capacityProvider, scaling); + } else { + const { + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + minExecutionEnvironments, + maxExecutionEnvironments, + } = scaling; + capacityProvider.addFunction(this, { + // Set explicitly so both attach paths emit identical templates; the + // ARN path must set it on the L1 resource and the CFN property has no + // documented default of its own + publishToLatestPublished: true, + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + ...(minExecutionEnvironments !== undefined || + maxExecutionEnvironments !== undefined + ? { + latestPublishedScalingConfig: { + minExecutionEnvironments, + maxExecutionEnvironments, + }, + } + : {}), + }); + } + } + + /** + * A capacity provider from another stack can only be referenced by ARN, and + * the imported construct has no `addFunction`, so set the equivalent L1 + * properties directly. + */ + #attachToCapacityProviderArn( + capacityProviderArn: string, + scaling: Omit, 'capacityProvider'> + ): void { + const { + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + minExecutionEnvironments, + maxExecutionEnvironments, + } = scaling; + const cfnFunction = this.node.defaultChild as CfnFunction; + cfnFunction.publishToLatestPublished = true; + cfnFunction.capacityProviderConfig = { + lambdaManagedInstancesCapacityProviderConfig: { + capacityProviderArn, + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + }, + }; + if ( + minExecutionEnvironments !== undefined || + maxExecutionEnvironments !== undefined + ) { + cfnFunction.functionScalingConfig = { + minExecutionEnvironments, + maxExecutionEnvironments, + }; + } + } } export { TestNodejsFunction }; diff --git a/packages/testing/src/types.ts b/packages/testing/src/types.ts index 82d7254100..3835db3f59 100644 --- a/packages/testing/src/types.ts +++ b/packages/testing/src/types.ts @@ -1,5 +1,6 @@ import type { App, Stack } from 'aws-cdk-lib'; import type { AttributeType, TableProps } from 'aws-cdk-lib/aws-dynamodb'; +import type { CapacityProvider } from 'aws-cdk-lib/aws-lambda'; import type { NodejsFunctionProps } from 'aws-cdk-lib/aws-lambda-nodejs'; import type { LogLevel } from './constants.js'; @@ -32,6 +33,48 @@ interface ExtraTestProps { * @default false */ createAlias?: boolean; + /** + * Options to run the function on Lambda Managed Instances (LMI). + * + * When set, the function is associated with the given capacity provider + * and published to the `$LATEST.PUBLISHED` version. The function name + * emitted in the stack output is qualified with `:$LATEST.PUBLISHED` so + * that invocations target the version served by the capacity provider. + * + * Cannot be combined with `createAlias`. + */ + lmi?: { + /** + * The capacity provider to associate the function with: either a + * construct in the same stack, or the ARN of a capacity provider that + * lives elsewhere (e.g. the run-scoped shared stack deployed by + * `lmi/deploySharedCapacityProvider.ts` in this package). + */ + capacityProvider: CapacityProvider | string; + /** + * The maximum number of concurrent invocations a single execution + * environment can handle. + * + * @default 10 + */ + perExecutionEnvironmentMaxConcurrency?: number; + /** + * The execution environment memory per vCPU, in GiB. + * + * @default 2.0 + */ + executionEnvironmentMemoryGiBPerVCpu?: number; + /** + * The minimum number of execution environments to maintain for the + * `$LATEST.PUBLISHED` version. + */ + minExecutionEnvironments?: number; + /** + * The maximum number of execution environments allowed for the + * `$LATEST.PUBLISHED` version. + */ + maxExecutionEnvironments?: number; + }; } type TestDynamodbTableProps = Omit<