From 4f0a8f20ba6c4dc1a85a938e7b4886048af21604 Mon Sep 17 00:00:00 2001 From: svozza Date: Wed, 8 Jul 2026 18:56:48 +0100 Subject: [PATCH 01/23] chore(tests): add e2e test for logger InvokeStore isolation on Lambda Managed Instances Adds an opt-in (RUN_LMI_TESTS) e2e suite that provisions an ephemeral Lambda Managed Instances capacity provider (dual-stack IPv6 VPC, no NAT, CloudWatch Logs interface endpoint) and proves the InvokeStore-backed log attribute isolation across invocations multiplexed into the same execution environment via a module-scoped promise barrier. Testing utils changes: - TestLmiCapacityProvider construct + ./resources/capacity-provider export - ExtraTestProps.lmi option on TestNodejsFunction (memorySize>=2048 floor, $LATEST.PUBLISHED qualified output) - includeTailLogs opt-out on invoke helpers (Tail unsupported on LMI) Related to #5092, findings posted on the issue. --- .github/workflows/run-e2e-tests.yml | 6 + package-lock.json | 5 +- packages/logger/package.json | 5 +- .../logger/tests/e2e/lmi.test.FunctionCode.ts | 49 +++++ packages/logger/tests/e2e/lmi.test.ts | 203 ++++++++++++++++++ packages/testing/package.json | 8 + packages/testing/src/invokeTestFunction.ts | 18 +- .../src/resources/TestLmiCapacityProvider.ts | 74 +++++++ .../src/resources/TestNodejsFunction.ts | 30 ++- packages/testing/src/types.ts | 50 +++++ 10 files changed, 442 insertions(+), 6 deletions(-) create mode 100644 packages/logger/tests/e2e/lmi.test.FunctionCode.ts create mode 100644 packages/logger/tests/e2e/lmi.test.ts create mode 100644 packages/testing/src/resources/TestLmiCapacityProvider.ts diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 79e1f0b24f..a4b2bcdf09 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -2,6 +2,11 @@ name: Run e2e Tests on: workflow_dispatch: + inputs: + run_lmi_tests: + description: 'Run Lambda Managed Instances (LMI) e2e tests' + type: boolean + default: false permissions: contents: read @@ -56,6 +61,7 @@ jobs: RUNTIME: nodejs${{ matrix.version }}x CI: true ARCH: ${{ matrix.arch }} + RUN_LMI_TESTS: ${{ inputs.run_lmi_tests }} 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 diff --git a/package-lock.json b/package-lock.json index 900b1fb320..0a0610bfac 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", diff --git a/packages/logger/package.json b/packages/logger/package.json index 4c70a5b369..8c0e1e5eee 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -73,7 +73,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-cloudwatch-logs": "^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..ce5280e494 --- /dev/null +++ b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts @@ -0,0 +1,49 @@ +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(); +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', { + executionEnvId, + sawPeer, + initializationType: process.env.AWS_LAMBDA_INITIALIZATION_TYPE ?? 'unset', + maxConcurrency: process.env.AWS_LAMBDA_MAX_CONCURRENCY ?? 'unset', + }); + logger.resetKeys(); + + return { invocationId: event.invocationId }; +}; diff --git a/packages/logger/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts new file mode 100644 index 0000000000..1f68779e6c --- /dev/null +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -0,0 +1,203 @@ +import { join } from 'node:path'; +import { + invokeFunctionOnce, + TestStack, +} from '@aws-lambda-powertools/testing-utils'; +import { TestLmiCapacityProvider } from '@aws-lambda-powertools/testing-utils/resources/capacity-provider'; +import { + CloudWatchLogsClient, + FilterLogEventsCommand, +} from '@aws-sdk/client-cloudwatch-logs'; +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, STACK_OUTPUT_LOG_GROUP } from './constants.js'; + +type IsolationLog = { + invocationKey: string; + executionEnvId: string; + sawPeer: boolean; + initializationType: string; + maxConcurrency: 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 an ephemeral capacity provider whose fleet + * is capped at the minimum size (12 vCPUs, which in practice hosts 8 of this + * function's ~1 vCPU execution environments). The LMI scheduler prefers + * scaling out to fresh environments over multiplexing, so the test fires more + * simultaneous invocations than the fleet can host as dedicated environments, + * forcing the overflow to be multiplexed into busy ones. The handler blocks + * on a module-scoped promise barrier (see `lmi.test.FunctionCode.ts`) until a + * peer invocation lands in the same environment, proving a genuine overlap. + * Without InvokeStore isolation, the overlapping invocations' appended keys + * would bleed into each other's log output. + */ +describe.runIf(process.env.RUN_LMI_TESTS === 'true')( + 'Logger E2E - Lambda Managed Instances', + () => { + // The LMI scheduler scales out to fresh execution environments until the + // capacity provider's fleet is saturated (8 environments with a 12 vCPU + // cap and ~1 vCPU environments) and only then multiplexes concurrent + // invocations into busy environments, so we need comfortably more + // concurrent invocations than the fleet can host + const invocationCount = 30; + + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: 'Lmi', + }, + }); + + // Location of the lambda function code + const lambdaFunctionCodeFilePath = join( + __dirname, + 'lmi.test.FunctionCode.ts' + ); + + const capacityProvider = new TestLmiCapacityProvider(testStack); + new LoggerTestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + // ACTIVE tracing compatibility with LMI is unverified + tracing: Tracing.DISABLED, + }, + { + logGroupOutputKey: STACK_OUTPUT_LOG_GROUP, + nameSuffix: 'LmiIsolation', + lmi: { + capacityProvider, + perExecutionEnvironmentMaxConcurrency: 10, + }, + } + ); + + let functionName: string; + let logGroupName: string; + let invokeStartTime: number; + + beforeAll(async () => { + await testStack.deploy(); + + functionName = testStack.findAndGetStackOutputValue('LmiIsolation'); + logGroupName = testStack.findAndGetStackOutputValue( + STACK_OUTPUT_LOG_GROUP + ); + + // 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) => { + await invokeFunctionOnce({ + functionName, + payload: { invocationId: 'warmup', role: 'warmup' }, + // Tail logs are not supported on capacity provider functions + includeTailLogs: false, + }).catch(retry); + }, + { + retries: 10, + factor: 2, + minTimeout: 5_000, + maxTimeout: 60_000, + } + ); + + invokeStartTime = Date.now(); + // 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 + await Promise.all( + Array.from({ length: invocationCount }, (_, index) => + invokeFunctionOnce({ + functionName, + payload: { invocationId: `inv-${index}`, role: 'test' }, + includeTailLogs: false, + }) + ) + ); + }, 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', async () => { + // Collect the isolation logs from CloudWatch, retrying until every + // invocation's log has been ingested + const client = new CloudWatchLogsClient({}); + const isolationLogs = await promiseRetry( + async (retry) => { + const logs: IsolationLog[] = []; + let nextToken: string | undefined; + do { + const response = await client.send( + new FilterLogEventsCommand({ + logGroupName, + filterPattern: '"LMI isolation test"', + startTime: invokeStartTime, + nextToken, + }) + ); + for (const event of response.events ?? []) { + const log = JSON.parse(event.message ?? '{}') as IsolationLog; + if (log.invocationKey?.startsWith('inv-')) { + logs.push(log); + } + } + nextToken = response.nextToken; + } while (nextToken); + + if (logs.length < invocationCount) { + return retry( + new Error( + `Expected ${invocationCount} isolation logs, got ${logs.length}` + ) + ); + } + return logs; + }, + { retries: 10, factor: 1, minTimeout: 5_000 } + ); + + expect(isolationLogs).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 log of isolationLogs) { + expect(log.initializationType).toBe('lambda-managed-instances'); + expect(log.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(isolationLogs.some((log) => log.sawPeer === true)).toBe(true); + + // Each invocation logged exactly its own key: without isolation, + // concurrent appendKeys calls within the shared environment would + // bleed across invocations while they were blocked on the barrier + const invocationKeys = isolationLogs + .map((log) => log.invocationKey) + .sort((a, b) => + Number(a.split('-')[1]) > Number(b.split('-')[1]) ? 1 : -1 + ); + expect(invocationKeys).toEqual( + Array.from({ length: invocationCount }, (_, index) => `inv-${index}`) + ); + }); + + 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..8f88d9717f 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -47,6 +47,10 @@ "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" + }, "./context": { "import": "./lib/esm/context.js", "require": "./lib/cjs/context.js" @@ -70,6 +74,10 @@ "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" + ], "types": [ "lib/cjs/types.d.ts", "lib/esm/types.d.ts" diff --git a/packages/testing/src/invokeTestFunction.ts b/packages/testing/src/invokeTestFunction.ts index 8ebdde714b..64988b2d6c 100644 --- a/packages/testing/src/invokeTestFunction.ts +++ b/packages/testing/src/invokeTestFunction.ts @@ -11,6 +11,7 @@ const lambdaClient = new LambdaClient({}); const invokeFunctionOnce = async ({ functionName, payload = {}, + includeTailLogs = true, }: Omit< InvokeTestFunctionOptions, 'times' | 'invocationMode' @@ -19,7 +20,9 @@ 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; not supported on + // functions configured with a capacity provider + LogType: includeTailLogs ? 'Tail' : 'None', Payload: fromUtf8(JSON.stringify(payload)), }) ); @@ -42,6 +45,7 @@ const invokeFunction = async ({ times = 1, invocationMode = 'PARALLEL', payload = {}, + includeTailLogs = true, }: InvokeTestFunctionOptions): Promise => { const invocationLogs: TestInvocationLogs[] = []; @@ -64,7 +68,11 @@ const invokeFunction = async ({ ? payload[index] : payload; - return invoke({ functionName, payload: invocationPayload }); + return invoke({ + functionName, + payload: invocationPayload, + includeTailLogs, + }); }) )) ); @@ -74,7 +82,11 @@ const invokeFunction = async ({ ? payload[index] : payload; invocationLogs.push( - await invokeFunctionOnce({ functionName, payload: invocationPayload }) + await invokeFunctionOnce({ + functionName, + payload: invocationPayload, + includeTailLogs, + }) ); } } diff --git a/packages/testing/src/resources/TestLmiCapacityProvider.ts b/packages/testing/src/resources/TestLmiCapacityProvider.ts new file mode 100644 index 0000000000..f02ed8971c --- /dev/null +++ b/packages/testing/src/resources/TestLmiCapacityProvider.ts @@ -0,0 +1,74 @@ +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 { + public constructor(stack: TestStack) { + 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[getArchitectureKey()]], + // 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..0fef51e381 100644 --- a/packages/testing/src/resources/TestNodejsFunction.ts +++ b/packages/testing/src/resources/TestNodejsFunction.ts @@ -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,30 @@ class TestNodejsFunction extends NodejsFunction { }); let outputValue = this.functionName; + if (extraProps.lmi) { + const { + capacityProvider, + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + minExecutionEnvironments, + maxExecutionEnvironments, + } = extraProps.lmi; + capacityProvider.addFunction(this, { + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + ...(minExecutionEnvironments !== undefined || + maxExecutionEnvironments !== undefined + ? { + latestPublishedScalingConfig: { + minExecutionEnvironments, + maxExecutionEnvironments, + }, + } + : {}), + }); + // 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', diff --git a/packages/testing/src/types.ts b/packages/testing/src/types.ts index 82d7254100..84ed46bd5a 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,46 @@ 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, + * which must live in the same stack, and is 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. + */ + capacityProvider: CapacityProvider; + /** + * 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< @@ -59,6 +100,15 @@ type InvokeTestFunctionOptions = { times?: number; invocationMode?: 'PARALLEL' | 'SEQUENTIAL'; payload?: Record | Array>; + /** + * Whether to request the tail of the execution log with the invocation. + * + * Not supported by functions running on Lambda Managed Instances; collect + * logs with the `LogTailer` instead. + * + * @default true + */ + includeTailLogs?: boolean; }; type ErrorField = { From a30896487492531f81bd1a94f67531e906030448 Mon Sep 17 00:00:00 2001 From: svozza Date: Wed, 8 Jul 2026 21:23:15 +0100 Subject: [PATCH 02/23] chore(tests): capture LMI e2e logs via stdout interception instead of CloudWatch polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handler tees process.stdout.write (the Logger's production write path) and returns its own log lines in the response payload, making log collection deterministic — no FilterLogEvents polling, no ingestion latency, no CloudWatch client in the test. Captured lines are filtered by the InvokeStore-scoped invocationKey rather than function_request_id: addContext stores the Lambda context in instance state, so under LMI multiplexing the request id stamped on log lines can belong to a different invocation. Tracked separately as a bug (see lmi-request-id-bug-handoff.md); once fixed, the filter should flip back to function_request_id as a regression check. --- .../logger/tests/e2e/lmi.test.FunctionCode.ts | 40 ++++- packages/logger/tests/e2e/lmi.test.ts | 145 ++++++++---------- 2 files changed, 96 insertions(+), 89 deletions(-) diff --git a/packages/logger/tests/e2e/lmi.test.FunctionCode.ts b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts index ce5280e494..63784de485 100644 --- a/packages/logger/tests/e2e/lmi.test.FunctionCode.ts +++ b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts @@ -5,6 +5,26 @@ 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 @@ -37,13 +57,23 @@ export const handler = async ( } } - logger.info('LMI isolation test', { + 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', - }); - logger.resetKeys(); - - return { invocationId: event.invocationId }; + // Only the lines this invocation emitted, selected by the + // InvokeStore-scoped invocation key (the attribute under test). + // Deliberately NOT filtered by function_request_id: addContext stores + // the Lambda context in instance state, so under multiplexing the + // request id stamped on log lines can belong to another invocation + // (see lmi-request-id-bug-handoff.md) + logs: capturedLogs.filter( + (log) => log.invocationKey === event.invocationId + ), + }; }; diff --git a/packages/logger/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts index 1f68779e6c..b384c6e3fa 100644 --- a/packages/logger/tests/e2e/lmi.test.ts +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -1,25 +1,24 @@ import { join } from 'node:path'; -import { - invokeFunctionOnce, - TestStack, -} from '@aws-lambda-powertools/testing-utils'; +import { TestStack } from '@aws-lambda-powertools/testing-utils'; import { TestLmiCapacityProvider } from '@aws-lambda-powertools/testing-utils/resources/capacity-provider'; -import { - CloudWatchLogsClient, - FilterLogEventsCommand, -} from '@aws-sdk/client-cloudwatch-logs'; +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, STACK_OUTPUT_LOG_GROUP } from './constants.js'; +import { RESOURCE_NAME_PREFIX } from './constants.js'; -type IsolationLog = { - invocationKey: string; +type IsolationResult = { + invocationId: string; executionEnvId: string; sawPeer: boolean; initializationType: string; maxConcurrency: string; + logs: Array<{ + message: string; + invocationKey?: string; + function_request_id?: string; + }>; }; /** @@ -37,6 +36,12 @@ type IsolationLog = { * peer invocation lands in the same environment, proving a genuine overlap. * Without InvokeStore isolation, the overlapping invocations' appended keys * would bleed into each other's log output. + * + * 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. */ describe.runIf(process.env.RUN_LMI_TESTS === 'true')( 'Logger E2E - Lambda Managed Instances', @@ -70,7 +75,6 @@ describe.runIf(process.env.RUN_LMI_TESTS === 'true')( tracing: Tracing.DISABLED, }, { - logGroupOutputKey: STACK_OUTPUT_LOG_GROUP, nameSuffix: 'LmiIsolation', lmi: { capacityProvider, @@ -79,28 +83,44 @@ describe.runIf(process.env.RUN_LMI_TESTS === 'true')( } ); + const lambdaClient = new LambdaClient({}); let functionName: string; - let logGroupName: string; - let invokeStartTime: number; + + 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'); - logGroupName = testStack.findAndGetStackOutputValue( - STACK_OUTPUT_LOG_GROUP - ); // 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) => { - await invokeFunctionOnce({ - functionName, - payload: { invocationId: 'warmup', role: 'warmup' }, - // Tail logs are not supported on capacity provider functions - includeTailLogs: false, - }).catch(retry); + await invokeOnce({ invocationId: 'warmup', role: 'warmup' }).catch( + retry + ); }, { retries: 10, @@ -110,68 +130,26 @@ describe.runIf(process.env.RUN_LMI_TESTS === 'true')( } ); - invokeStartTime = Date.now(); // 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 - await Promise.all( + results = await Promise.all( Array.from({ length: invocationCount }, (_, index) => - invokeFunctionOnce({ - functionName, - payload: { invocationId: `inv-${index}`, role: 'test' }, - includeTailLogs: false, - }) + invokeOnce({ invocationId: `inv-${index}`, role: 'test' }) ) ); }, 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', async () => { - // Collect the isolation logs from CloudWatch, retrying until every - // invocation's log has been ingested - const client = new CloudWatchLogsClient({}); - const isolationLogs = await promiseRetry( - async (retry) => { - const logs: IsolationLog[] = []; - let nextToken: string | undefined; - do { - const response = await client.send( - new FilterLogEventsCommand({ - logGroupName, - filterPattern: '"LMI isolation test"', - startTime: invokeStartTime, - nextToken, - }) - ); - for (const event of response.events ?? []) { - const log = JSON.parse(event.message ?? '{}') as IsolationLog; - if (log.invocationKey?.startsWith('inv-')) { - logs.push(log); - } - } - nextToken = response.nextToken; - } while (nextToken); - - if (logs.length < invocationCount) { - return retry( - new Error( - `Expected ${invocationCount} isolation logs, got ${logs.length}` - ) - ); - } - return logs; - }, - { retries: 10, factor: 1, minTimeout: 5_000 } - ); - - expect(isolationLogs).toHaveLength(invocationCount); + 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 log of isolationLogs) { - expect(log.initializationType).toBe('lambda-managed-instances'); - expect(log.maxConcurrency).toBe('10'); + 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 @@ -179,19 +157,18 @@ describe.runIf(process.env.RUN_LMI_TESTS === 'true')( // 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(isolationLogs.some((log) => log.sawPeer === true)).toBe(true); - - // Each invocation logged exactly its own key: without isolation, - // concurrent appendKeys calls within the shared environment would - // bleed across invocations while they were blocked on the barrier - const invocationKeys = isolationLogs - .map((log) => log.invocationKey) - .sort((a, b) => - Number(a.split('-')[1]) > Number(b.split('-')[1]) ? 1 : -1 + 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(invocationKeys).toEqual( - Array.from({ length: invocationCount }, (_, index) => `inv-${index}`) - ); + expect(isolationLogs).toHaveLength(1); + expect(isolationLogs[0].invocationKey).toBe(result.invocationId); + } }); afterAll(async () => { From cae8d138414b1e7df8e8a1eb0d5121055302909e Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 9 Jul 2026 23:26:46 +0100 Subject: [PATCH 03/23] chore(tests): run LMI e2e suite unconditionally and filter captured logs by request id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the lambda context now scoped per invocation under LMI (#5430), the handler selects its own log lines by function_request_id — an independent per-invocation attribute — and the test asserts the InvokeStore-scoped invocationKey on those lines, making the suite a regression check for both the context scoping and appendKeys isolation. The suite adds ~4 minutes to the logger e2e cell, inside the agreed budget for running on every e2e dispatch, so the RUN_LMI_TESTS gate and workflow input are removed. --- .github/workflows/run-e2e-tests.yml | 6 - .../logger/tests/e2e/lmi.test.FunctionCode.ts | 14 +- packages/logger/tests/e2e/lmi.test.ts | 247 +++++++++--------- 3 files changed, 129 insertions(+), 138 deletions(-) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index a4b2bcdf09..79e1f0b24f 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -2,11 +2,6 @@ name: Run e2e Tests on: workflow_dispatch: - inputs: - run_lmi_tests: - description: 'Run Lambda Managed Instances (LMI) e2e tests' - type: boolean - default: false permissions: contents: read @@ -61,7 +56,6 @@ jobs: RUNTIME: nodejs${{ matrix.version }}x CI: true ARCH: ${{ matrix.arch }} - RUN_LMI_TESTS: ${{ inputs.run_lmi_tests }} 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 diff --git a/packages/logger/tests/e2e/lmi.test.FunctionCode.ts b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts index 63784de485..4142e12dbc 100644 --- a/packages/logger/tests/e2e/lmi.test.FunctionCode.ts +++ b/packages/logger/tests/e2e/lmi.test.FunctionCode.ts @@ -66,14 +66,14 @@ export const handler = async ( 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 - // InvokeStore-scoped invocation key (the attribute under test). - // Deliberately NOT filtered by function_request_id: addContext stores - // the Lambda context in instance state, so under multiplexing the - // request id stamped on log lines can belong to another invocation - // (see lmi-request-id-bug-handoff.md) + // 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.invocationKey === event.invocationId + (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 index b384c6e3fa..33dd77b28d 100644 --- a/packages/logger/tests/e2e/lmi.test.ts +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -43,138 +43,135 @@ type IsolationResult = { * returns them in the response payload, making log collection fully * deterministic while exercising the production log write path. */ -describe.runIf(process.env.RUN_LMI_TESTS === 'true')( - 'Logger E2E - Lambda Managed Instances', - () => { - // The LMI scheduler scales out to fresh execution environments until the - // capacity provider's fleet is saturated (8 environments with a 12 vCPU - // cap and ~1 vCPU environments) and only then multiplexes concurrent - // invocations into busy environments, so we need comfortably more - // concurrent invocations than the fleet can host - const invocationCount = 30; - - const testStack = new TestStack({ - stackNameProps: { - stackNamePrefix: RESOURCE_NAME_PREFIX, - testName: 'Lmi', +describe('Logger E2E - Lambda Managed Instances', () => { + // The LMI scheduler scales out to fresh execution environments until the + // capacity provider's fleet is saturated (8 environments with a 12 vCPU + // cap and ~1 vCPU environments) and only then multiplexes concurrent + // invocations into busy environments, so we need comfortably more + // concurrent invocations than the fleet can host + const invocationCount = 30; + + const testStack = new TestStack({ + stackNameProps: { + stackNamePrefix: RESOURCE_NAME_PREFIX, + testName: 'Lmi', + }, + }); + + // Location of the lambda function code + const lambdaFunctionCodeFilePath = join( + __dirname, + 'lmi.test.FunctionCode.ts' + ); + + const capacityProvider = new TestLmiCapacityProvider(testStack); + new LoggerTestNodejsFunction( + testStack, + { + entry: lambdaFunctionCodeFilePath, + // ACTIVE tracing compatibility with LMI is unverified + tracing: Tracing.DISABLED, + }, + { + nameSuffix: 'LmiIsolation', + lmi: { + capacityProvider, + perExecutionEnvironmentMaxConcurrency: 10, }, - }); - - // Location of the lambda function code - const lambdaFunctionCodeFilePath = join( - __dirname, - 'lmi.test.FunctionCode.ts' + } + ); + + 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), + }) ); - - const capacityProvider = 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'); - - // 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) => { - await invokeOnce({ invocationId: 'warmup', role: 'warmup' }).catch( - retry - ); - }, - { - retries: 10, - factor: 2, - minTimeout: 5_000, - maxTimeout: 60_000, - } + if (response.FunctionError) { + throw new Error( + `Invocation ${payload.invocationId} failed: ${response.FunctionError}` ); + } + return JSON.parse( + Buffer.from(response.Payload ?? new Uint8Array()).toString() + ); + }; - // 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' }) - ) - ); - }, 1_200_000); // VPC + capacity provider + instance boot can exceed the default hook timeout + let results: IsolationResult[]; - it('isolates log attributes across concurrent invocations in the same execution environment', () => { - expect(results).toHaveLength(invocationCount); + beforeAll(async () => { + await testStack.deploy(); - // 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'); - } + functionName = testStack.findAndGetStackOutputValue('LmiIsolation'); - // 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' + // 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) => { + await invokeOnce({ invocationId: 'warmup', role: 'warmup' }).catch( + retry ); - expect(isolationLogs).toHaveLength(1); - expect(isolationLogs[0].invocationKey).toBe(result.invocationId); + }, + { + retries: 10, + factor: 2, + minTimeout: 5_000, + maxTimeout: 60_000, } - }); + ); - afterAll(async () => { - if (!process.env.DISABLE_TEARDOWN) { - await testStack.destroy(); - } - }, 1_200_000); - } -); + // 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' }) + ) + ); + }, 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); +}); From f54675d16554b236897d5dd320ff8b51049c4455 Mon Sep 17 00:00:00 2001 From: svozza Date: Fri, 10 Jul 2026 00:11:30 +0100 Subject: [PATCH 04/23] chore(tests): raise vitest worker cap for logger e2e runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e suites are I/O-bound (waiting on CloudFormation), but vitest sizes its worker pool from CPU cores, so on 2-core CI runners only 3 of the 6 logger e2e files ran concurrently and the rest queued — each then paying its own stack deploy after waiting. With 8 workers all suites deploy their stacks up front and the cell duration approaches the slowest single suite (~6 min measured) instead of a serialized ~8 min. CloudFormation read-API pressure from the extra concurrent stack monitors is bounded by the existing DescribeStackEvents polling patch in the testing package (10s interval per stack). --- packages/logger/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index 8c0e1e5eee..dc2f9391d9 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -15,9 +15,9 @@ "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 --maxWorkers=8 tests/e2e", + "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run --maxWorkers=8 tests/e2e", + "test:e2e": "vitest --run --maxWorkers=8 tests/e2e", "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", From 9970bc195ce0b85a7528084af7f44fc2497c60b0 Mon Sep 17 00:00:00 2001 From: svozza Date: Fri, 10 Jul 2026 00:32:08 +0100 Subject: [PATCH 05/23] Revert "chore(tests): raise vitest worker cap for logger e2e runs" With all 36 matrix cells sharing one account, the extra concurrent stack operations from 8-worker logger cells pushed the account-wide CloudFormation API rate over the edge: three unrelated cells failed with 'Throttling: Rate exceeded' on stack deploys. Back to default worker sizing; cell-duration work moves to the run-scoped shared capacity provider follow-up, which removes per-cell VPC/CP stacks entirely instead of racing them. --- packages/logger/package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index dc2f9391d9..8c0e1e5eee 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -15,9 +15,9 @@ "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 --maxWorkers=8 tests/e2e", - "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run --maxWorkers=8 tests/e2e", - "test:e2e": "vitest --run --maxWorkers=8 tests/e2e", + "test:e2e:nodejs22x": "RUNTIME=nodejs22x vitest --run tests/e2e", + "test:e2e:nodejs24x": "RUNTIME=nodejs24x vitest --run tests/e2e", + "test:e2e": "vitest --run tests/e2e", "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", From 87d71f4393c4c2f16b172e90199b7c4303485b24 Mon Sep 17 00:00:00 2001 From: svozza Date: Fri, 10 Jul 2026 09:09:49 +0100 Subject: [PATCH 06/23] chore(tests): add run-scoped shared LMI capacity provider CLI Instead of every LMI suite provisioning its own capacity provider + VPC, a workflow run can deploy one shared capacity provider per architecture up front and pass its ARN to each suite via LMI_CAPACITY_PROVIDER_ARN. - add packages/testing/src/lmi/cli.ts with deploy/destroy commands that manage a run-scoped LmiShared-- stack and print the capacity provider ARN on stdout - TestNodejsFunction accepts a capacity provider ARN string and attaches the function via L1 CfnFunction.capacityProviderConfig (an imported capacity provider has no addFunction) - ExtraTestProps.lmi.capacityProvider widened to CapacityProvider | string - TestLmiCapacityProvider ctor loosened to Pick so the CLI can build the stack standalone - logger lmi.test.ts reads LMI_CAPACITY_PROVIDER_ARN, falling back to an ephemeral per-suite capacity provider when unset --- packages/logger/tests/e2e/lmi.test.ts | 8 +- packages/testing/src/lmi/cli.ts | 112 ++++++++++++++++++ .../src/resources/TestLmiCapacityProvider.ts | 2 +- .../src/resources/TestNodejsFunction.ts | 52 +++++--- packages/testing/src/types.ts | 16 +-- 5 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 packages/testing/src/lmi/cli.ts diff --git a/packages/logger/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts index 33dd77b28d..1b45d805cc 100644 --- a/packages/logger/tests/e2e/lmi.test.ts +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -64,7 +64,13 @@ describe('Logger E2E - Lambda Managed Instances', () => { 'lmi.test.FunctionCode.ts' ); - const capacityProvider = new TestLmiCapacityProvider(testStack); + // In CI a setup job deploys one shared capacity provider per architecture + // (see the lmi CLI 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 + const capacityProvider = + process.env.LMI_CAPACITY_PROVIDER_ARN ?? + new TestLmiCapacityProvider(testStack); new LoggerTestNodejsFunction( testStack, { diff --git a/packages/testing/src/lmi/cli.ts b/packages/testing/src/lmi/cli.ts new file mode 100644 index 0000000000..6145aad6e9 --- /dev/null +++ b/packages/testing/src/lmi/cli.ts @@ -0,0 +1,112 @@ +import { readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from 'node:util'; +import { + type ICloudAssemblySource, + StackSelectionStrategy, + Toolkit, +} from '@aws-cdk/toolkit-lib'; +import { App, CfnOutput, Stack } from 'aws-cdk-lib'; +import { getArchitectureKey } from '../helpers.js'; +import { TestLmiCapacityProvider } from '../resources/TestLmiCapacityProvider.js'; + +/** + * CLI to manage the run-scoped shared Lambda Managed Instances (LMI) + * capacity provider stacks. + * + * 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 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. + * + * Usage: + * ``` + * ARCH=x86_64 node lib/esm/lmi/cli.js deploy --run-id 12345 + * ARCH=x86_64 node lib/esm/lmi/cli.js destroy --run-id 12345 + * ``` + * The deploy command prints `LMI_CAPACITY_PROVIDER_ARN=` on stdout as + * its last line so callers (e.g. a GitHub Actions setup job) can capture it. + */ + +const buildStackName = (runId: string): string => + `LmiShared-${runId}-${getArchitectureKey().replace('_', '-')}`; + +const buildApp = (stackName: string): { app: App; stack: Stack } => { + const app = new App(); + const stack = new Stack(app, stackName, { + tags: { + Service: 'Powertools-for-AWS-e2e-tests', + }, + }); + const capacityProvider = new TestLmiCapacityProvider({ stack }); + new CfnOutput(stack, 'CapacityProviderArn', { + value: capacityProvider.capacityProviderArn, + }); + + return { app, stack }; +}; + +const makeAssembly = async ( + cli: Toolkit, + app: App, + stackName: string +): Promise<{ cx: ICloudAssemblySource; outputFilePath: string }> => { + const outdir = join(tmpdir(), `${stackName}-powertools-e2e-testing`); + const outputFilePath = join(outdir, 'outputs.json'); + const cx = await cli.fromAssemblyBuilder(async () => app.synth(), { + outdir, + }); + return { cx, outputFilePath }; +}; + +const main = async (): Promise => { + const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + 'run-id': { + type: 'string', + default: process.env.GITHUB_RUN_ID ?? 'local', + }, + }, + }); + const action = positionals[0]; + if (action !== 'deploy' && action !== 'destroy') { + throw new Error('Usage: cli.js [--run-id ]'); + } + + const stackName = buildStackName(values['run-id']); + const { app } = buildApp(stackName); + const cli = new Toolkit({ color: false }); + const { cx, outputFilePath } = await makeAssembly(cli, app, stackName); + + if (action === 'deploy') { + await cli.deploy(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + outputsFile: outputFilePath, + }); + const outputs = JSON.parse(await readFile(outputFilePath, 'utf-8'))[ + stackName + ]; + console.log(`LMI_CAPACITY_PROVIDER_ARN=${outputs.CapacityProviderArn}`); + return; + } + + await cli.destroy(cx, { + stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, + }); + console.log(`Destroyed ${stackName}`); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/testing/src/resources/TestLmiCapacityProvider.ts b/packages/testing/src/resources/TestLmiCapacityProvider.ts index f02ed8971c..a1988a119d 100644 --- a/packages/testing/src/resources/TestLmiCapacityProvider.ts +++ b/packages/testing/src/resources/TestLmiCapacityProvider.ts @@ -26,7 +26,7 @@ import type { TestStack } from '../TestStack.js'; * so create one per test suite and share it across the functions in that suite. */ class TestLmiCapacityProvider extends CapacityProvider { - public constructor(stack: TestStack) { + public constructor(stack: Pick) { const resourceId = randomUUID().substring(0, 5); const vpc = new Vpc(stack.stack, `vpc-${resourceId}`, { ipProtocol: IpProtocol.DUAL_STACK, diff --git a/packages/testing/src/resources/TestNodejsFunction.ts b/packages/testing/src/resources/TestNodejsFunction.ts index 0fef51e381..33eea7331c 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'; @@ -70,19 +70,43 @@ class TestNodejsFunction extends NodejsFunction { minExecutionEnvironments, maxExecutionEnvironments, } = extraProps.lmi; - capacityProvider.addFunction(this, { - perExecutionEnvironmentMaxConcurrency, - executionEnvironmentMemoryGiBPerVCpu, - ...(minExecutionEnvironments !== undefined || - maxExecutionEnvironments !== undefined - ? { - latestPublishedScalingConfig: { - minExecutionEnvironments, - maxExecutionEnvironments, - }, - } - : {}), - }); + if (typeof capacityProvider === 'string') { + // 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 + const cfnFunction = this.node.defaultChild as CfnFunction; + cfnFunction.publishToLatestPublished = true; + cfnFunction.capacityProviderConfig = { + lambdaManagedInstancesCapacityProviderConfig: { + capacityProviderArn: capacityProvider, + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + }, + }; + if ( + minExecutionEnvironments !== undefined || + maxExecutionEnvironments !== undefined + ) { + cfnFunction.functionScalingConfig = { + minExecutionEnvironments, + maxExecutionEnvironments, + }; + } + } else { + capacityProvider.addFunction(this, { + perExecutionEnvironmentMaxConcurrency, + executionEnvironmentMemoryGiBPerVCpu, + ...(minExecutionEnvironments !== undefined || + maxExecutionEnvironments !== undefined + ? { + latestPublishedScalingConfig: { + minExecutionEnvironments, + maxExecutionEnvironments, + }, + } + : {}), + }); + } // LMI serves the $LATEST.PUBLISHED version, so invocations must target it outputValue = `${this.functionName}:$LATEST.PUBLISHED`; } diff --git a/packages/testing/src/types.ts b/packages/testing/src/types.ts index 84ed46bd5a..f771c00f22 100644 --- a/packages/testing/src/types.ts +++ b/packages/testing/src/types.ts @@ -36,19 +36,21 @@ interface ExtraTestProps { /** * Options to run the function on Lambda Managed Instances (LMI). * - * When set, the function is associated with the given capacity provider, - * which must live in the same stack, and is 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. + * 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. + * 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 the + * `lmi` CLI in this package). */ - capacityProvider: CapacityProvider; + capacityProvider: CapacityProvider | string; /** * The maximum number of concurrent invocations a single execution * environment can handle. From 67837599d38b27c746f2bde1bf58f6b49051860c Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 13 Jul 2026 21:32:25 +0100 Subject: [PATCH 07/23] fix(tests): validate LMI CLI run-id to prevent path traversal The --run-id argument flows into a CloudFormation stack name and, via join(tmpdir(), ...), into the assembly output path that is later read back. A crafted value (e.g. containing ../) could escape tmpdir(), so restrict it to the alphanumerics-and-hyphens set CloudFormation already requires for stack names and reject anything else at the boundary. --- packages/testing/src/lmi/cli.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/lmi/cli.ts b/packages/testing/src/lmi/cli.ts index 6145aad6e9..2dfcf43f10 100644 --- a/packages/testing/src/lmi/cli.ts +++ b/packages/testing/src/lmi/cli.ts @@ -37,8 +37,24 @@ import { TestLmiCapacityProvider } from '../resources/TestLmiCapacityProvider.js * its last line so callers (e.g. a GitHub Actions setup job) can capture it. */ +/** + * A run id becomes part of a CloudFormation stack name and a temp directory + * path, so it is restricted to the characters CloudFormation already allows in + * a stack name (alphanumerics and hyphens). This also prevents a crafted + * `--run-id` (e.g. containing `../`) from escaping `tmpdir()` when it is joined + * into the assembly output path. + */ +const assertValidRunId = (runId: string): string => { + if (!/^[A-Za-z0-9-]+$/.test(runId)) { + throw new Error( + `Invalid --run-id "${runId}": only alphanumerics and hyphens are allowed` + ); + } + return runId; +}; + const buildStackName = (runId: string): string => - `LmiShared-${runId}-${getArchitectureKey().replace('_', '-')}`; + `LmiShared-${assertValidRunId(runId)}-${getArchitectureKey().replace('_', '-')}`; const buildApp = (stackName: string): { app: App; stack: Stack } => { const app = new App(); From b5e9d4d7d17ad6652201de9f441a8454b3b5e729 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 13 Jul 2026 21:42:55 +0100 Subject: [PATCH 08/23] fix(tests): validate constructed assembly path stays within tmpdir The path-injection scanner's taint analysis doesn't recognise the run-id regex guard as sanitisation, and its guidance is to validate the constructed path before touching the file system. Resolve the assembly output directory and assert it stays within os.tmpdir() before it is written to or read back, keeping the run-id validation as the input-side guard for defence in depth. --- packages/testing/src/lmi/cli.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/testing/src/lmi/cli.ts b/packages/testing/src/lmi/cli.ts index 2dfcf43f10..b9666ff3ac 100644 --- a/packages/testing/src/lmi/cli.ts +++ b/packages/testing/src/lmi/cli.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve, sep } from 'node:path'; import { parseArgs } from 'node:util'; import { type ICloudAssemblySource, @@ -76,7 +76,14 @@ const makeAssembly = async ( app: App, stackName: string ): Promise<{ cx: ICloudAssemblySource; outputFilePath: string }> => { - const outdir = join(tmpdir(), `${stackName}-powertools-e2e-testing`); + const base = tmpdir(); + const outdir = resolve(base, `${stackName}-powertools-e2e-testing`); + // Defence in depth: the constructed output directory must stay within the + // system temp directory. Combined with run-id validation this guarantees the + // paths we write to and read back cannot be steered outside tmpdir(). + if (outdir !== base && !outdir.startsWith(base + sep)) { + throw new Error(`Refusing to use output directory outside ${base}`); + } const outputFilePath = join(outdir, 'outputs.json'); const cx = await cli.fromAssemblyBuilder(async () => app.synth(), { outdir, From e70faea8ca3108dcdfe13a035511b2a17e8fd886 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 13 Jul 2026 22:08:09 +0100 Subject: [PATCH 09/23] refactor(tests): extract LMI capacity-provider attachment helpers The constructor's cognitive complexity hit 17 (limit 15) once it carried the nested ARN-vs-construct capacity-provider branching. Move that logic into two private methods so the constructor keeps a flat structure and the two attachment strategies read independently. No behaviour change. --- .../src/resources/TestNodejsFunction.ts | 111 +++++++++++------- 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/packages/testing/src/resources/TestNodejsFunction.ts b/packages/testing/src/resources/TestNodejsFunction.ts index 33eea7331c..431684e16c 100644 --- a/packages/testing/src/resources/TestNodejsFunction.ts +++ b/packages/testing/src/resources/TestNodejsFunction.ts @@ -63,50 +63,7 @@ class TestNodejsFunction extends NodejsFunction { let outputValue = this.functionName; if (extraProps.lmi) { - const { - capacityProvider, - perExecutionEnvironmentMaxConcurrency, - executionEnvironmentMemoryGiBPerVCpu, - minExecutionEnvironments, - maxExecutionEnvironments, - } = extraProps.lmi; - if (typeof capacityProvider === 'string') { - // 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 - const cfnFunction = this.node.defaultChild as CfnFunction; - cfnFunction.publishToLatestPublished = true; - cfnFunction.capacityProviderConfig = { - lambdaManagedInstancesCapacityProviderConfig: { - capacityProviderArn: capacityProvider, - perExecutionEnvironmentMaxConcurrency, - executionEnvironmentMemoryGiBPerVCpu, - }, - }; - if ( - minExecutionEnvironments !== undefined || - maxExecutionEnvironments !== undefined - ) { - cfnFunction.functionScalingConfig = { - minExecutionEnvironments, - maxExecutionEnvironments, - }; - } - } else { - capacityProvider.addFunction(this, { - perExecutionEnvironmentMaxConcurrency, - executionEnvironmentMemoryGiBPerVCpu, - ...(minExecutionEnvironments !== undefined || - maxExecutionEnvironments !== undefined - ? { - latestPublishedScalingConfig: { - minExecutionEnvironments, - maxExecutionEnvironments, - }, - } - : {}), - }); - } + this.#attachToCapacityProvider(extraProps.lmi); // LMI serves the $LATEST.PUBLISHED version, so invocations must target it outputValue = `${this.functionName}:$LATEST.PUBLISHED`; } @@ -123,6 +80,72 @@ 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, { + 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 }; From d4d430618851b81150588b4c9295b99ddc84fff7 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 11:13:23 +0100 Subject: [PATCH 10/23] refactor(tests): replace LMI CLI with TestStack-based workflow scripts --- .github/workflows/run-e2e-tests.yml | 101 ++++++++++++- packages/logger/tests/e2e/lmi.test.ts | 17 ++- packages/testing/src/TestStack.ts | 38 +++-- packages/testing/src/lmi/cli.ts | 135 ------------------ .../src/lmi/deploySharedCapacityProvider.ts | 29 ++++ .../src/lmi/destroySharedCapacityProvider.ts | 26 ++++ .../src/lmi/sharedCapacityProviderStack.ts | 56 ++++++++ packages/testing/src/types.ts | 4 +- 8 files changed, 248 insertions(+), 158 deletions(-) delete mode 100644 packages/testing/src/lmi/cli.ts create mode 100644 packages/testing/src/lmi/deploySharedCapacityProvider.ts create mode 100644 packages/testing/src/lmi/destroySharedCapacityProvider.ts create mode 100644 packages/testing/src/lmi/sharedCapacityProviderStack.ts diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 79e1f0b24f..9586c55773 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -7,8 +7,53 @@ 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 + 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 ARN is NOT passed to the test jobs as a job + # output: it contains the AWS account id, which is masked, and GitHub + # silently drops job outputs containing masked values. The stack name is + # deterministic (LmiShared--), so each test job resolves + # the ARN itself from the stack outputs. + - name: Deploy shared LMI capacity provider (x86_64) + env: + ARCH: x86_64 + run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js + - name: Deploy shared LMI capacity provider (arm64) + env: + ARCH: arm64 + run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js + run-e2e-tests-on-utils: runs-on: ubuntu-latest + needs: setup-lmi-capacity-providers env: NODE_ENV: dev environment: e2e-tests @@ -51,6 +96,20 @@ jobs: 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) + echo "LMI_CAPACITY_PROVIDER_ARN=$arn" >> "$GITHUB_ENV" - name: Run e2e ${{ matrix.package }}-${{ matrix.version }}-${{ matrix.arch }} env: RUNTIME: nodejs${{ matrix.version }}x @@ -58,4 +117,44 @@ 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 }} + + teardown-lmi-capacity-providers: + runs-on: ubuntu-latest + needs: [setup-lmi-capacity-providers, run-e2e-tests-on-utils] + 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 + - name: Destroy shared LMI capacity provider (x86_64) + # Attempt both teardowns even if one fails or the setup job only + # partially succeeded; destroying a non-existent stack is a no-op + continue-on-error: true + env: + ARCH: x86_64 + run: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js + - name: Destroy shared LMI capacity provider (arm64) + continue-on-error: true + env: + ARCH: arm64 + run: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js diff --git a/packages/logger/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts index 1b45d805cc..6b4af99190 100644 --- a/packages/logger/tests/e2e/lmi.test.ts +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -65,12 +65,17 @@ describe('Logger E2E - Lambda Managed Instances', () => { ); // In CI a setup job deploys one shared capacity provider per architecture - // (see the lmi CLI 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 - const capacityProvider = - process.env.LMI_CAPACITY_PROVIDER_ARN ?? - new TestLmiCapacityProvider(testStack); + // (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, { diff --git a/packages/testing/src/TestStack.ts b/packages/testing/src/TestStack.ts index 49253d96b4..77386cad3b 100644 --- a/packages/testing/src/TestStack.ts +++ b/packages/testing/src/TestStack.ts @@ -138,23 +138,30 @@ 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. + */ + async #synthAssembly(): Promise { + return await this.#cli.fromAssemblyBuilder(async () => this.app.synth(), { + outdir: this.#outdir(), + }); + } + /** * 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 +179,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/lmi/cli.ts b/packages/testing/src/lmi/cli.ts deleted file mode 100644 index b9666ff3ac..0000000000 --- a/packages/testing/src/lmi/cli.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join, resolve, sep } from 'node:path'; -import { parseArgs } from 'node:util'; -import { - type ICloudAssemblySource, - StackSelectionStrategy, - Toolkit, -} from '@aws-cdk/toolkit-lib'; -import { App, CfnOutput, Stack } from 'aws-cdk-lib'; -import { getArchitectureKey } from '../helpers.js'; -import { TestLmiCapacityProvider } from '../resources/TestLmiCapacityProvider.js'; - -/** - * CLI to manage the run-scoped shared Lambda Managed Instances (LMI) - * capacity provider stacks. - * - * 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 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. - * - * Usage: - * ``` - * ARCH=x86_64 node lib/esm/lmi/cli.js deploy --run-id 12345 - * ARCH=x86_64 node lib/esm/lmi/cli.js destroy --run-id 12345 - * ``` - * The deploy command prints `LMI_CAPACITY_PROVIDER_ARN=` on stdout as - * its last line so callers (e.g. a GitHub Actions setup job) can capture it. - */ - -/** - * A run id becomes part of a CloudFormation stack name and a temp directory - * path, so it is restricted to the characters CloudFormation already allows in - * a stack name (alphanumerics and hyphens). This also prevents a crafted - * `--run-id` (e.g. containing `../`) from escaping `tmpdir()` when it is joined - * into the assembly output path. - */ -const assertValidRunId = (runId: string): string => { - if (!/^[A-Za-z0-9-]+$/.test(runId)) { - throw new Error( - `Invalid --run-id "${runId}": only alphanumerics and hyphens are allowed` - ); - } - return runId; -}; - -const buildStackName = (runId: string): string => - `LmiShared-${assertValidRunId(runId)}-${getArchitectureKey().replace('_', '-')}`; - -const buildApp = (stackName: string): { app: App; stack: Stack } => { - const app = new App(); - const stack = new Stack(app, stackName, { - tags: { - Service: 'Powertools-for-AWS-e2e-tests', - }, - }); - const capacityProvider = new TestLmiCapacityProvider({ stack }); - new CfnOutput(stack, 'CapacityProviderArn', { - value: capacityProvider.capacityProviderArn, - }); - - return { app, stack }; -}; - -const makeAssembly = async ( - cli: Toolkit, - app: App, - stackName: string -): Promise<{ cx: ICloudAssemblySource; outputFilePath: string }> => { - const base = tmpdir(); - const outdir = resolve(base, `${stackName}-powertools-e2e-testing`); - // Defence in depth: the constructed output directory must stay within the - // system temp directory. Combined with run-id validation this guarantees the - // paths we write to and read back cannot be steered outside tmpdir(). - if (outdir !== base && !outdir.startsWith(base + sep)) { - throw new Error(`Refusing to use output directory outside ${base}`); - } - const outputFilePath = join(outdir, 'outputs.json'); - const cx = await cli.fromAssemblyBuilder(async () => app.synth(), { - outdir, - }); - return { cx, outputFilePath }; -}; - -const main = async (): Promise => { - const { positionals, values } = parseArgs({ - allowPositionals: true, - options: { - 'run-id': { - type: 'string', - default: process.env.GITHUB_RUN_ID ?? 'local', - }, - }, - }); - const action = positionals[0]; - if (action !== 'deploy' && action !== 'destroy') { - throw new Error('Usage: cli.js [--run-id ]'); - } - - const stackName = buildStackName(values['run-id']); - const { app } = buildApp(stackName); - const cli = new Toolkit({ color: false }); - const { cx, outputFilePath } = await makeAssembly(cli, app, stackName); - - if (action === 'deploy') { - await cli.deploy(cx, { - stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, - outputsFile: outputFilePath, - }); - const outputs = JSON.parse(await readFile(outputFilePath, 'utf-8'))[ - stackName - ]; - console.log(`LMI_CAPACITY_PROVIDER_ARN=${outputs.CapacityProviderArn}`); - return; - } - - await cli.destroy(cx, { - stacks: { strategy: StackSelectionStrategy.ALL_STACKS }, - }); - console.log(`Destroyed ${stackName}`); -}; - -main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/packages/testing/src/lmi/deploySharedCapacityProvider.ts b/packages/testing/src/lmi/deploySharedCapacityProvider.ts new file mode 100644 index 0000000000..9fb3deb063 --- /dev/null +++ b/packages/testing/src/lmi/deploySharedCapacityProvider.ts @@ -0,0 +1,29 @@ +import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack.js'; + +/** + * Deploy the run-scoped shared LMI capacity provider stack for the current + * architecture. + * + * Intended to run as a workflow setup job step (after the packages have been + * built): + * ```yaml + * - run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js + * env: + * ARCH: x86_64 + * ``` + * The ARN is deliberately not exposed as a job output: it contains the AWS + * account id, which CI masks, and GitHub silently drops job outputs that + * contain masked values. The stack name is deterministic, so test jobs + * resolve the ARN from the stack outputs instead (see the e2e workflow). + */ +const main = async (): Promise => { + const testStack = buildSharedCapacityProviderStack(); + await testStack.deploy(); + const arn = testStack.findAndGetStackOutputValue('CapacityProviderArn'); + console.log(`LMI_CAPACITY_PROVIDER_ARN=${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..29f38cc80d --- /dev/null +++ b/packages/testing/src/lmi/destroySharedCapacityProvider.ts @@ -0,0 +1,26 @@ +import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack.js'; + +/** + * Destroy the run-scoped shared LMI capacity provider stack for the current + * architecture. + * + * Intended to run as a workflow teardown job step (with `if: always()` so the + * stack is removed even when the test jobs fail): + * ```yaml + * - run: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js + * env: + * ARCH: ${{ matrix.arch }} + * ``` + * The stack name is deterministic for a given run id and architecture, so the + * teardown job reconstructs the same stack the setup job deployed. + */ +const main = async (): Promise => { + const testStack = buildSharedCapacityProviderStack(); + await testStack.destroy(); + console.log(`Destroyed ${testStack.stack.stackName}`); +}; + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/testing/src/lmi/sharedCapacityProviderStack.ts b/packages/testing/src/lmi/sharedCapacityProviderStack.ts new file mode 100644 index 0000000000..f1bb18f60d --- /dev/null +++ b/packages/testing/src/lmi/sharedCapacityProviderStack.ts @@ -0,0 +1,56 @@ +import { App, CfnOutput, Stack } from 'aws-cdk-lib'; +import { getArchitectureKey } from '../helpers.js'; +import { TestLmiCapacityProvider } from '../resources/TestLmiCapacityProvider.js'; +import { TestStack } from '../TestStack.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 = (): TestStack => { + 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` + ); + } + const stackName = `LmiShared-${runId}-${getArchitectureKey().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', + testName: 'sharedCapacityProvider', + }, + app, + stack, + }); + const capacityProvider = new TestLmiCapacityProvider(testStack); + new CfnOutput(stack, 'CapacityProviderArn', { + value: capacityProvider.capacityProviderArn, + }); + + return testStack; +}; + +export { buildSharedCapacityProviderStack }; diff --git a/packages/testing/src/types.ts b/packages/testing/src/types.ts index f771c00f22..f38a91c08f 100644 --- a/packages/testing/src/types.ts +++ b/packages/testing/src/types.ts @@ -47,8 +47,8 @@ interface ExtraTestProps { /** * 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 the - * `lmi` CLI in this package). + * lives elsewhere (e.g. the run-scoped shared stack deployed by + * `lmi/deploySharedCapacityProvider.ts` in this package). */ capacityProvider: CapacityProvider | string; /** From 813e50e716460d6eaf8eb4ce6218346ba4263557 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 11:46:26 +0100 Subject: [PATCH 11/23] refactor(tests): run LMI e2e suites in a dedicated job gated on the shared capacity provider --- .github/workflows/run-e2e-tests.yml | 61 +++++++++++++++++++++++++++-- packages/logger/package.json | 7 ++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 9586c55773..a0231ddb60 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -51,9 +51,13 @@ jobs: ARCH: arm64 run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js + # 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 - needs: setup-lmi-capacity-providers env: NODE_ENV: dev environment: e2e-tests @@ -96,6 +100,50 @@ jobs: role-to-assume: ${{ secrets.E2E_IAM_ROLE_ARN }} aws-region: eu-west-1 mask-aws-account-id: true + - name: Run 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 -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 + 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 @@ -110,18 +158,23 @@ jobs: --query "Stacks[0].Outputs[?OutputKey=='CapacityProviderArn'].OutputValue" \ --output text) echo "LMI_CAPACITY_PROVIDER_ARN=$arn" >> "$GITHUB_ENV" - - name: Run e2e ${{ matrix.package }}-${{ matrix.version }}-${{ matrix.arch }} + - 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 -w ${{ matrix.package }} + 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. + # If a run fails and you need to retry, use "re-run all jobs": "re-run + # failed jobs" skips the (succeeded) setup job while the stacks it deployed + # have already been destroyed by this teardown. teardown-lmi-capacity-providers: runs-on: ubuntu-latest - needs: [setup-lmi-capacity-providers, run-e2e-tests-on-utils] + needs: [setup-lmi-capacity-providers, run-e2e-tests-lmi] if: always() env: NODE_ENV: dev diff --git a/packages/logger/package.json b/packages/logger/package.json index 8c0e1e5eee..a70a5e06e2 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", From d8199a30ceba805d6616e781045054b739ec9abd Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 11:56:53 +0100 Subject: [PATCH 12/23] refactor(tests): deploy and destroy LMI capacity provider stacks concurrently --- .github/workflows/run-e2e-tests.yml | 33 ++++++----------- .../src/lmi/deploySharedCapacityProvider.ts | 28 +++++++++------ .../src/lmi/destroySharedCapacityProvider.ts | 35 +++++++++++++------ .../src/lmi/sharedCapacityProviderStack.ts | 13 +++++-- 4 files changed, 63 insertions(+), 46 deletions(-) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index a0231ddb60..ce4db7f76b 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -37,18 +37,12 @@ jobs: role-to-assume: ${{ secrets.E2E_IAM_ROLE_ARN }} aws-region: eu-west-1 mask-aws-account-id: true - # The capacity provider ARN is NOT passed to the test jobs as a job - # output: it contains the AWS account id, which is masked, and GitHub - # silently drops job outputs containing masked values. The stack name is - # deterministic (LmiShared--), so each test job resolves - # the ARN itself from the stack outputs. - - name: Deploy shared LMI capacity provider (x86_64) - env: - ARCH: x86_64 - run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js - - name: Deploy shared LMI capacity provider (arm64) - env: - ARCH: arm64 + # 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: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js # Runs every package's e2e suites EXCEPT the LMI ones (each package's @@ -199,15 +193,8 @@ jobs: role-to-assume: ${{ secrets.E2E_IAM_ROLE_ARN }} aws-region: eu-west-1 mask-aws-account-id: true - - name: Destroy shared LMI capacity provider (x86_64) - # Attempt both teardowns even if one fails or the setup job only - # partially succeeded; destroying a non-existent stack is a no-op - continue-on-error: true - env: - ARCH: x86_64 - run: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js - - name: Destroy shared LMI capacity provider (arm64) - continue-on-error: true - env: - ARCH: arm64 + # 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: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js diff --git a/packages/testing/src/lmi/deploySharedCapacityProvider.ts b/packages/testing/src/lmi/deploySharedCapacityProvider.ts index 9fb3deb063..9370b94847 100644 --- a/packages/testing/src/lmi/deploySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/deploySharedCapacityProvider.ts @@ -1,26 +1,34 @@ +import { TEST_ARCHITECTURES } from '../constants.js'; import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack.js'; /** - * Deploy the run-scoped shared LMI capacity provider stack for the current - * architecture. + * 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: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js - * env: - * ARCH: x86_64 * ``` - * The ARN is deliberately not exposed as a job output: it contains the AWS + * 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 name is deterministic, so test jobs + * 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 => { - const testStack = buildSharedCapacityProviderStack(); - await testStack.deploy(); - const arn = testStack.findAndGetStackOutputValue('CapacityProviderArn'); - console.log(`LMI_CAPACITY_PROVIDER_ARN=${arn}`); + await Promise.all( + (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) + // Build all stacks synchronously before any deploy starts: construction + // reads/writes the ambient ARCH environment variable, so it must not + // interleave with other builds + .map((architecture) => buildSharedCapacityProviderStack(architecture)) + .map(async (testStack) => { + await testStack.deploy(); + const arn = testStack.findAndGetStackOutputValue('CapacityProviderArn'); + console.log(`${testStack.stack.stackName}: ${arn}`); + }) + ); }; main().catch((error) => { diff --git a/packages/testing/src/lmi/destroySharedCapacityProvider.ts b/packages/testing/src/lmi/destroySharedCapacityProvider.ts index 29f38cc80d..b36729d5b6 100644 --- a/packages/testing/src/lmi/destroySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/destroySharedCapacityProvider.ts @@ -1,23 +1,38 @@ +import { TEST_ARCHITECTURES } from '../constants.js'; import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack.js'; /** - * Destroy the run-scoped shared LMI capacity provider stack for the current - * architecture. + * 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 - * stack is removed even when the test jobs fail): + * stacks are removed even when the test jobs fail): * ```yaml * - run: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js - * env: - * ARCH: ${{ matrix.arch }} * ``` - * The stack name is deterministic for a given run id and architecture, so the - * teardown job reconstructs the same stack the setup job deployed. + * 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 main = async (): Promise => { - const testStack = buildSharedCapacityProviderStack(); - await testStack.destroy(); - console.log(`Destroyed ${testStack.stack.stackName}`); + const results = await Promise.allSettled( + (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) + // Build all stacks synchronously before any destroy starts: + // construction reads/writes the ambient ARCH environment variable, so + // it must not interleave with other builds + .map((architecture) => buildSharedCapacityProviderStack(architecture)) + .map(async (testStack) => { + await testStack.destroy(); + console.log(`Destroyed ${testStack.stack.stackName}`); + }) + ); + for (const result of results) { + if (result.status === 'rejected') { + console.error(result.reason); + process.exitCode = 1; + } + } }; main().catch((error) => { diff --git a/packages/testing/src/lmi/sharedCapacityProviderStack.ts b/packages/testing/src/lmi/sharedCapacityProviderStack.ts index f1bb18f60d..869b591148 100644 --- a/packages/testing/src/lmi/sharedCapacityProviderStack.ts +++ b/packages/testing/src/lmi/sharedCapacityProviderStack.ts @@ -1,5 +1,5 @@ import { App, CfnOutput, Stack } from 'aws-cdk-lib'; -import { getArchitectureKey } from '../helpers.js'; +import type { TEST_ARCHITECTURES } from '../constants.js'; import { TestLmiCapacityProvider } from '../resources/TestLmiCapacityProvider.js'; import { TestStack } from '../TestStack.js'; @@ -22,14 +22,21 @@ import { TestStack } from '../TestStack.js'; * it in a fresh process — so it deliberately does not use * `generateTestUniqueName()`, which embeds a random component. */ -const buildSharedCapacityProviderStack = (): TestStack => { +const buildSharedCapacityProviderStack = ( + architecture: keyof typeof TEST_ARCHITECTURES +): TestStack => { 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` ); } - const stackName = `LmiShared-${runId}-${getArchitectureKey().replace('_', '-')}`; + // The construct tree below is keyed on the ambient ARCH environment + // variable (via getArchitectureKey()). Construction is synchronous, so + // setting it here cannot race a concurrent build for another architecture; + // only the deploy/destroy network phases run concurrently. + process.env.ARCH = architecture; + const stackName = `LmiShared-${runId}-${architecture.replace('_', '-')}`; const app = new App(); const stack = new Stack(app, stackName, { From b87de6a69cfe94e80d6c61ff24327c26177303ed Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 12:00:34 +0100 Subject: [PATCH 13/23] refactor(tests): pass architecture explicitly instead of mutating process.env --- .../src/lmi/deploySharedCapacityProvider.ts | 3 --- .../src/lmi/destroySharedCapacityProvider.ts | 3 --- .../src/lmi/sharedCapacityProviderStack.ts | 7 +------ .../src/resources/TestLmiCapacityProvider.ts | 15 +++++++++++++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/testing/src/lmi/deploySharedCapacityProvider.ts b/packages/testing/src/lmi/deploySharedCapacityProvider.ts index 9370b94847..133035379b 100644 --- a/packages/testing/src/lmi/deploySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/deploySharedCapacityProvider.ts @@ -19,9 +19,6 @@ import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack. const main = async (): Promise => { await Promise.all( (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) - // Build all stacks synchronously before any deploy starts: construction - // reads/writes the ambient ARCH environment variable, so it must not - // interleave with other builds .map((architecture) => buildSharedCapacityProviderStack(architecture)) .map(async (testStack) => { await testStack.deploy(); diff --git a/packages/testing/src/lmi/destroySharedCapacityProvider.ts b/packages/testing/src/lmi/destroySharedCapacityProvider.ts index b36729d5b6..9cbe0d8ca4 100644 --- a/packages/testing/src/lmi/destroySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/destroySharedCapacityProvider.ts @@ -18,9 +18,6 @@ import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack. const main = async (): Promise => { const results = await Promise.allSettled( (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) - // Build all stacks synchronously before any destroy starts: - // construction reads/writes the ambient ARCH environment variable, so - // it must not interleave with other builds .map((architecture) => buildSharedCapacityProviderStack(architecture)) .map(async (testStack) => { await testStack.destroy(); diff --git a/packages/testing/src/lmi/sharedCapacityProviderStack.ts b/packages/testing/src/lmi/sharedCapacityProviderStack.ts index 869b591148..adbe140b03 100644 --- a/packages/testing/src/lmi/sharedCapacityProviderStack.ts +++ b/packages/testing/src/lmi/sharedCapacityProviderStack.ts @@ -31,11 +31,6 @@ const buildSharedCapacityProviderStack = ( `Invalid run id "${runId}": only alphanumerics and hyphens are allowed` ); } - // The construct tree below is keyed on the ambient ARCH environment - // variable (via getArchitectureKey()). Construction is synchronous, so - // setting it here cannot race a concurrent build for another architecture; - // only the deploy/destroy network phases run concurrently. - process.env.ARCH = architecture; const stackName = `LmiShared-${runId}-${architecture.replace('_', '-')}`; const app = new App(); @@ -52,7 +47,7 @@ const buildSharedCapacityProviderStack = ( app, stack, }); - const capacityProvider = new TestLmiCapacityProvider(testStack); + const capacityProvider = new TestLmiCapacityProvider(testStack, architecture); new CfnOutput(stack, 'CapacityProviderArn', { value: capacityProvider.capacityProviderArn, }); diff --git a/packages/testing/src/resources/TestLmiCapacityProvider.ts b/packages/testing/src/resources/TestLmiCapacityProvider.ts index a1988a119d..a25ff51d59 100644 --- a/packages/testing/src/resources/TestLmiCapacityProvider.ts +++ b/packages/testing/src/resources/TestLmiCapacityProvider.ts @@ -26,7 +26,18 @@ import type { TestStack } from '../TestStack.js'; * so create one per test suite and share it across the functions in that suite. */ class TestLmiCapacityProvider extends CapacityProvider { - public constructor(stack: Pick) { + /** + * @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, @@ -63,7 +74,7 @@ class TestLmiCapacityProvider extends CapacityProvider { super(stack.stack, `cp-${resourceId}`, { subnets: vpc.privateSubnets, securityGroups: [securityGroup], - architectures: [TEST_ARCHITECTURES[getArchitectureKey()]], + architectures: [TEST_ARCHITECTURES[architecture]], // The service minimum; keeps the fleet as small as possible so that // concurrent invocations share execution environments maxVCpuCount: 12, From 3ba1f8544becf06a6644d9dd941271c3a4fc60ec Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 12:08:50 +0100 Subject: [PATCH 14/23] fix(tests): disambiguate architectures in shared capacity provider progress logs --- packages/testing/src/lmi/sharedCapacityProviderStack.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/lmi/sharedCapacityProviderStack.ts b/packages/testing/src/lmi/sharedCapacityProviderStack.ts index adbe140b03..221c1a3ff7 100644 --- a/packages/testing/src/lmi/sharedCapacityProviderStack.ts +++ b/packages/testing/src/lmi/sharedCapacityProviderStack.ts @@ -42,7 +42,10 @@ const buildSharedCapacityProviderStack = ( const testStack = new TestStack({ stackNameProps: { stackNamePrefix: 'LmiShared', - testName: 'sharedCapacityProvider', + // 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, From 90752a7aec7efc8d0647d53a0e22054281850f9f Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 12:15:23 +0100 Subject: [PATCH 15/23] chore(tests): add phase markers to LMI e2e suite output --- packages/logger/tests/e2e/lmi.test.ts | 34 +++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/logger/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts index 6b4af99190..7e7646600f 100644 --- a/packages/logger/tests/e2e/lmi.test.ts +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -1,3 +1,4 @@ +import { Console } from 'node:console'; import { join } from 'node:path'; import { TestStack } from '@aws-lambda-powertools/testing-utils'; import { TestLmiCapacityProvider } from '@aws-lambda-powertools/testing-utils/resources/capacity-provider'; @@ -43,6 +44,16 @@ type IsolationResult = { * 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', () => { // The LMI scheduler scales out to fresh execution environments until the // capacity provider's fleet is saturated (8 environments with a 12 vCPU @@ -122,13 +133,21 @@ describe('Logger E2E - Lambda Managed Instances', () => { 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) => { + async (retry, attempt) => { await invokeOnce({ invocationId: 'warmup', role: 'warmup' }).catch( - retry + (error) => { + testConsole.log( + `[lmi] warmup attempt ${attempt} failed, retrying...` + ); + retry(error); + } ); }, { @@ -138,6 +157,9 @@ describe('Logger E2E - Lambda Managed Instances', () => { 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 @@ -148,6 +170,14 @@ describe('Logger E2E - Lambda Managed Instances', () => { 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', () => { From f2b3c0f52c2a53b0d0ff979454debfc306761456 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 12:59:43 +0100 Subject: [PATCH 16/23] docs(tests): clarify retry semantics for LMI vs non-LMI e2e jobs --- .github/workflows/run-e2e-tests.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index ce4db7f76b..9a969e82e6 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -163,9 +163,11 @@ jobs: # 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. - # If a run fails and you need to retry, use "re-run all jobs": "re-run - # failed jobs" skips the (succeeded) setup job while the stacks it deployed - # have already been destroyed by this teardown. + # 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 needs: [setup-lmi-capacity-providers, run-e2e-tests-lmi] From ed214b5584f1d225381ba7d2960b99a5e69eb440 Mon Sep 17 00:00:00 2001 From: svozza Date: Mon, 20 Jul 2026 14:23:49 +0100 Subject: [PATCH 17/23] chore(tests): invoke LMI capacity provider scripts via npm scripts --- .github/workflows/run-e2e-tests.yml | 4 ++-- packages/testing/package.json | 2 ++ packages/testing/src/lmi/deploySharedCapacityProvider.ts | 2 +- packages/testing/src/lmi/destroySharedCapacityProvider.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 9a969e82e6..02e715c2bd 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -43,7 +43,7 @@ jobs: # are deterministic (LmiShared--), so each test job # resolves the ARN itself from the stack outputs. - name: Deploy shared LMI capacity providers (all architectures) - run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js + 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 @@ -199,4 +199,4 @@ jobs: # 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: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js + run: npm run lmi:destroy -w packages/testing diff --git a/packages/testing/package.json b/packages/testing/package.json index 8f88d9717f..f7b31aca23 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", diff --git a/packages/testing/src/lmi/deploySharedCapacityProvider.ts b/packages/testing/src/lmi/deploySharedCapacityProvider.ts index 133035379b..50969f900f 100644 --- a/packages/testing/src/lmi/deploySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/deploySharedCapacityProvider.ts @@ -9,7 +9,7 @@ import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack. * Intended to run as a workflow setup job step (after the packages have been * built): * ```yaml - * - run: node packages/testing/lib/esm/lmi/deploySharedCapacityProvider.js + * - 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 diff --git a/packages/testing/src/lmi/destroySharedCapacityProvider.ts b/packages/testing/src/lmi/destroySharedCapacityProvider.ts index 9cbe0d8ca4..d1f9a1b6e1 100644 --- a/packages/testing/src/lmi/destroySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/destroySharedCapacityProvider.ts @@ -8,7 +8,7 @@ import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack. * 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: node packages/testing/lib/esm/lmi/destroySharedCapacityProvider.js + * - 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 From f7ca73d1802056bb36fa30fd2bfee3ad4a912e86 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 6 Aug 2026 14:33:23 +0100 Subject: [PATCH 18/23] fix(tests): opt out of clobberEnv to make concurrent CDK synths safe Two TestStack instances synthesizing at once (the shared LMI capacity provider stacks, one per architecture, deployed concurrently) both run fromAssemblyBuilder, which by default temporarily replaces the global process.env with an immutable proxy for the synth window. Interleaved synth windows race on that swap. Pass clobberEnv: false; the assembly builder does not read the injected env, so opting out is safe. --- packages/testing/src/TestStack.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/testing/src/TestStack.ts b/packages/testing/src/TestStack.ts index 77386cad3b..e43fc1436a 100644 --- a/packages/testing/src/TestStack.ts +++ b/packages/testing/src/TestStack.ts @@ -147,10 +147,18 @@ class TestStack { /** * 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, }); } From ba2859469cd2e31f7ff32540c41f6d745b442c14 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 6 Aug 2026 14:33:39 +0100 Subject: [PATCH 19/23] fix(tests): sweep orphaned LMI function stacks before deleting providers A function attached to a shared capacity provider by ARN keeps the provider's ENIs in use, and that cross-stack link is invisible to CloudFormation, so a function stack orphaned by a cancelled or timed-out cell makes the provider-stack delete fail with DELETE_FAILED and leaks an EC2 fleet plus VPC. - embed an Lmi marker and the run id in the LMI function stack name so the teardown job can attribute and find orphans (see lmi/naming.ts) - teardown now sweeps leftover Lmi- function stacks and waits for them to be gone before deleting the provider stacks, and retries the provider teardown once to absorb throttling and ENI detachment - add timeout-minutes: 60 to the three LMI jobs so a hung deploy/cell can't keep EC2 fleets alive for the 6-hour default - fail the ARN resolution step when describe-stacks returns empty/None instead of silently falling back to per-suite provisioning Residual leak paths (workflow-level cancellation, DISABLE_TEARDOWN, LmiShared-local-*) are tracked as the scheduled sweeper in #5519. --- .github/workflows/run-e2e-tests.yml | 12 +++ package-lock.json | 1 + packages/logger/tests/e2e/lmi.test.ts | 43 ++++++---- packages/testing/package.json | 9 ++ .../src/lmi/destroySharedCapacityProvider.ts | 40 +++++++-- packages/testing/src/lmi/naming.ts | 38 +++++++++ .../src/lmi/sharedCapacityProviderStack.ts | 9 +- .../src/lmi/sweepOrphanedFunctionStacks.ts | 83 +++++++++++++++++++ 8 files changed, 207 insertions(+), 28 deletions(-) create mode 100644 packages/testing/src/lmi/naming.ts create mode 100644 packages/testing/src/lmi/sweepOrphanedFunctionStacks.ts diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml index 02e715c2bd..a4b12d654e 100644 --- a/.github/workflows/run-e2e-tests.yml +++ b/.github/workflows/run-e2e-tests.yml @@ -14,6 +14,7 @@ jobs: # packages/testing/src/lmi/sharedCapacityProviderStack.ts). setup-lmi-capacity-providers: runs-on: ubuntu-latest + timeout-minutes: 60 env: NODE_ENV: dev environment: e2e-tests @@ -108,6 +109,7 @@ jobs: # 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 @@ -151,6 +153,15 @@ jobs: --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: @@ -170,6 +181,7 @@ jobs: # 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: diff --git a/package-lock.json b/package-lock.json index 0a0610bfac..47d622d6eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8767,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/tests/e2e/lmi.test.ts b/packages/logger/tests/e2e/lmi.test.ts index 7e7646600f..ec0b0ece6f 100644 --- a/packages/logger/tests/e2e/lmi.test.ts +++ b/packages/logger/tests/e2e/lmi.test.ts @@ -1,6 +1,7 @@ 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'; @@ -27,16 +28,22 @@ type IsolationResult = { * attributes on Lambda Managed Instances (LMI), where multiple invocations run * concurrently within the same execution environment. * - * The function is associated with an ephemeral capacity provider whose fleet - * is capped at the minimum size (12 vCPUs, which in practice hosts 8 of this - * function's ~1 vCPU execution environments). The LMI scheduler prefers - * scaling out to fresh environments over multiplexing, so the test fires more - * simultaneous invocations than the fleet can host as dedicated environments, - * forcing the overflow to be multiplexed into busy ones. The handler blocks - * on a module-scoped promise barrier (see `lmi.test.FunctionCode.ts`) until a - * peer invocation lands in the same environment, proving a genuine overlap. - * Without InvokeStore isolation, the overlapping invocations' appended keys - * would bleed into each other's log output. + * 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 @@ -55,17 +62,21 @@ const testConsole = new Console({ }); describe('Logger E2E - Lambda Managed Instances', () => { - // The LMI scheduler scales out to fresh execution environments until the - // capacity provider's fleet is saturated (8 environments with a 12 vCPU - // cap and ~1 vCPU environments) and only then multiplexes concurrent - // invocations into busy environments, so we need comfortably more - // concurrent invocations than the fleet can host + // 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: 'Lmi', + testName: lmiFunctionStackTestName(), }, }); diff --git a/packages/testing/package.json b/packages/testing/package.json index f7b31aca23..12c1ddffce 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -53,6 +53,10 @@ "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" @@ -80,6 +84,10 @@ "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" @@ -110,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/lmi/destroySharedCapacityProvider.ts b/packages/testing/src/lmi/destroySharedCapacityProvider.ts index d1f9a1b6e1..fd7b573ced 100644 --- a/packages/testing/src/lmi/destroySharedCapacityProvider.ts +++ b/packages/testing/src/lmi/destroySharedCapacityProvider.ts @@ -1,5 +1,6 @@ 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 @@ -15,7 +16,7 @@ import { buildSharedCapacityProviderStack } from './sharedCapacityProviderStack. * destroy failure is reported but does not prevent the other architecture's * teardown from being attempted. */ -const main = async (): Promise => { +const destroyProviderStacks = async (): Promise => { const results = await Promise.allSettled( (Object.keys(TEST_ARCHITECTURES) as (keyof typeof TEST_ARCHITECTURES)[]) .map((architecture) => buildSharedCapacityProviderStack(architecture)) @@ -24,11 +25,40 @@ const main = async (): Promise => { console.log(`Destroyed ${testStack.stack.stackName}`); }) ); - for (const result of results) { - if (result.status === 'rejected') { - console.error(result.reason); - process.exitCode = 1; + 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(); } }; 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 index 221c1a3ff7..213b0b0de4 100644 --- a/packages/testing/src/lmi/sharedCapacityProviderStack.ts +++ b/packages/testing/src/lmi/sharedCapacityProviderStack.ts @@ -2,6 +2,7 @@ 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 @@ -25,13 +26,7 @@ import { TestStack } from '../TestStack.js'; const buildSharedCapacityProviderStack = ( architecture: keyof typeof TEST_ARCHITECTURES ): TestStack => { - 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` - ); - } - const stackName = `LmiShared-${runId}-${architecture.replace('_', '-')}`; + const stackName = `LmiShared-${getRunId()}-${architecture.replace('_', '-')}`; const app = new App(); const stack = new Stack(app, stackName, { 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 }; From c07bee8b7d0396f93ec1d2a9d5c2f6e1f4d4b9f3 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 6 Aug 2026 14:33:55 +0100 Subject: [PATCH 20/23] fix(tests): set publishToLatestPublished on the construct attach path The ARN attach path sets publishToLatestPublished on the L1 resource explicitly; the construct path relied on CDK's undocumented default. Set it on both so the two paths emit identical templates and the behaviour doesn't depend on an undocumented service default. --- packages/testing/src/resources/TestNodejsFunction.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/testing/src/resources/TestNodejsFunction.ts b/packages/testing/src/resources/TestNodejsFunction.ts index 431684e16c..a24897e876 100644 --- a/packages/testing/src/resources/TestNodejsFunction.ts +++ b/packages/testing/src/resources/TestNodejsFunction.ts @@ -97,6 +97,10 @@ class TestNodejsFunction extends NodejsFunction { 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 || From b40b226a9eaa39ec9e7da18c9b0595ff0b52e46b Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 6 Aug 2026 14:33:57 +0100 Subject: [PATCH 21/23] refactor(tests): drop unused includeTailLogs invoke option The option was added so the LMI suite could opt out of Tail logs (Tail is unsupported on capacity-provider functions), but the suite now invokes directly and collects logs from the response payload, leaving the option uncalled. Remove it and document the Tail constraint on invokeFunctionOnce so it isn't reintroduced without the guard. --- packages/testing/src/invokeTestFunction.ts | 17 +++++++++-------- packages/testing/src/types.ts | 9 --------- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/packages/testing/src/invokeTestFunction.ts b/packages/testing/src/invokeTestFunction.ts index 64988b2d6c..8a3653afa6 100644 --- a/packages/testing/src/invokeTestFunction.ts +++ b/packages/testing/src/invokeTestFunction.ts @@ -6,12 +6,17 @@ 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, payload = {}, - includeTailLogs = true, }: Omit< InvokeTestFunctionOptions, 'times' | 'invocationMode' @@ -20,9 +25,8 @@ const invokeFunctionOnce = async ({ new InvokeCommand({ FunctionName: functionName, InvocationType: 'RequestResponse', - // Wait until execution completes and return all logs; not supported on - // functions configured with a capacity provider - LogType: includeTailLogs ? 'Tail' : 'None', + // Wait until execution completes and return all logs + LogType: 'Tail', Payload: fromUtf8(JSON.stringify(payload)), }) ); @@ -45,7 +49,6 @@ const invokeFunction = async ({ times = 1, invocationMode = 'PARALLEL', payload = {}, - includeTailLogs = true, }: InvokeTestFunctionOptions): Promise => { const invocationLogs: TestInvocationLogs[] = []; @@ -71,7 +74,6 @@ const invokeFunction = async ({ return invoke({ functionName, payload: invocationPayload, - includeTailLogs, }); }) )) @@ -85,7 +87,6 @@ const invokeFunction = async ({ await invokeFunctionOnce({ functionName, payload: invocationPayload, - includeTailLogs, }) ); } diff --git a/packages/testing/src/types.ts b/packages/testing/src/types.ts index f38a91c08f..3835db3f59 100644 --- a/packages/testing/src/types.ts +++ b/packages/testing/src/types.ts @@ -102,15 +102,6 @@ type InvokeTestFunctionOptions = { times?: number; invocationMode?: 'PARALLEL' | 'SEQUENTIAL'; payload?: Record | Array>; - /** - * Whether to request the tail of the execution log with the invocation. - * - * Not supported by functions running on Lambda Managed Instances; collect - * logs with the `LogTailer` instead. - * - * @default true - */ - includeTailLogs?: boolean; }; type ErrorField = { From 7894356e73bdd34db71d0e2a9d9d961f8756de8a Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 6 Aug 2026 14:33:59 +0100 Subject: [PATCH 22/23] chore(tests): fix logger e2e SDK dependencies Drop @aws-sdk/client-cloudwatch-logs (unused since log capture moved off CloudWatch polling) and declare @aws-sdk/client-lambda, which the LMI e2e suite imports directly and previously resolved only via workspace hoisting. --- packages/logger/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/logger/package.json b/packages/logger/package.json index a70a5e06e2..ac6e3bbd7d 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -75,7 +75,7 @@ "main": "./lib/cjs/index.js", "devDependencies": { "@aws-lambda-powertools/testing-utils": "file:../testing", - "@aws-sdk/client-cloudwatch-logs": "^3.1079.0", + "@aws-sdk/client-lambda": "^3.1079.0", "@types/promise-retry": "^1.1.3", "promise-retry": "^2.0.1" }, From e0d809c64c8c39a7883a60cdbad1d10bd1d6b308 Mon Sep 17 00:00:00 2001 From: svozza Date: Thu, 6 Aug 2026 15:01:39 +0100 Subject: [PATCH 23/23] fix(tests): log the real stack name in deploy/destroy progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The progress lines used testName, which is run through generateTestUniqueName and so mislabels stacks created from an explicitly-named Stack object — the shared LMI provider stack logged as `LmiShared-24-x86--arm64` instead of its real `LmiShared--` name. Log stack.stackName, the actual deployed name, which is correct for both generated and explicit names. --- packages/testing/src/TestStack.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/testing/src/TestStack.ts b/packages/testing/src/TestStack.ts index e43fc1436a..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)) {