diff --git a/.env b/.env index 00993d713..ab68a8589 100644 --- a/.env +++ b/.env @@ -2,7 +2,7 @@ # Customize these values as needed for testing both local and on github # Handlers to build -HANDLERS_TO_BUILD=basic-lambda +HANDLERS_TO_BUILD="basic-lambda basic-sqs http-basic-lambda basic-lambda-concurrent" HANDLER=basic-lambda @@ -11,3 +11,6 @@ OUTPUT_DIR=test/dockerized/tasks # Max concurrent Lambda invocations for LMI mode RIE_MAX_CONCURRENCY=4 + +# Branch of containerized-test-runner-for-aws-lambda to clone +TEST_RUNNER_BRANCH=dmelfi/multi-concurrency-test diff --git a/.github/workflows/dockerized-test.yml b/.github/workflows/dockerized-test.yml index 01c2df9e3..7c09aa5bd 100644 --- a/.github/workflows/dockerized-test.yml +++ b/.github/workflows/dockerized-test.yml @@ -44,4 +44,37 @@ jobs: with: suiteFileArray: '["./test/dockerized/suites/*.json"]' dockerImageName: 'local/test-base' - taskFolder: './test/dockerized/tasks' \ No newline at end of file + taskFolder: './test/dockerized/tasks' + + dockerized-test-concurrent: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + - name: Build Lambda artifacts for testing + run: | + mkdir -p test/dockerized/tasks + HANDLERS_TO_BUILD="basic-lambda-concurrent" OUTPUT_DIR="$(pwd)/test/dockerized/tasks" make build-examples + ls -la test/dockerized/tasks/ + + - name: Build base test image with RIE and custom entrypoint + run: docker build -t local/test-base -f Dockerfile.test . + + - name: Create Docker network for concurrent tests + run: docker network create concurrent-test-net + + - name: Run concurrent scenarios + uses: aws/containerized-test-runner-for-aws-lambda@dmelfi/multi-concurrency-test + with: + suiteFileArray: '[]' + dockerImageName: 'local/test-base' + taskFolder: './test/dockerized/tasks' + scenarioDir: './test/dockerized/scenarios' + dockerSharedNetwork: 'concurrent-test-net' + + - name: Remove Docker network + if: always() + run: docker network rm concurrent-test-net || true diff --git a/.gitignore b/.gitignore index 0652944cd..ac6cee53e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,52 @@ -target +# Rust +target/ +**/*.rs.bk +*.pdb + +# Cargo /.cargo -lambda-runtime/libtest.rmeta -lambda-integration-tests/target Cargo.lock -.test-runner -# Built AWS Lambda zipfile -lambda.zip +# IDE +.vscode/ +.idea/ +*.iml +*.ipr +*.iws +.kiro/ -# output.json from example docs -output.json +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# Environment +.env +*.env.local -.aws-sam -build -.vscode +# AWS / SAM +.aws-sam/ +build/ +output.json +lambda.zip -node_modules -cdk.out +# CDK +node_modules/ +cdk.out/ # Test artifacts -Dockerfile.test-with-tasks +.test-runner/ test/dockerized/tasks/ +Dockerfile.test-with-tasks +.test/ + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ + +# Misc +lambda-runtime/libtest.rmeta +lambda-integration-tests/target/ diff --git a/Makefile b/Makefile index d4ffa4ba4..39122a8ea 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ INTEG_EXTENSIONS := extension-fn extension-trait logs-trait # Using musl to run extensions on both AL1 and AL2 INTEG_ARCH := x86_64-unknown-linux-musl RIE_MAX_CONCURRENCY ?= 4 +TEST_RUNNER_BRANCH ?= main OUTPUT_DIR ?= test/dockerized/tasks HANDLERS_TO_BUILD ?= HANDLER ?= @@ -14,7 +15,7 @@ HANDLER ?= -include .env export -.PHONY: help pr-check integration-tests check-event-features fmt build-examples test-rie test-rie-lmi nuke test-dockerized +.PHONY: help pr-check integration-tests check-event-features fmt build-examples build-test-runner test-rie test-rie-lmi nuke test-dockerized test-dockerized-concurrent .DEFAULT_GOAL := help @@ -129,23 +130,39 @@ build-examples: nuke: docker kill $$(docker ps -q) -test-dockerized: build-examples - @echo "Running dockerized tests locally..." - +build-test-runner: build-examples @echo "Building base Docker image with RIE and custom entrypoint..." docker build \ -t local/test-base \ -f Dockerfile.test \ . - + @echo "Setting up containerized test runner..." @if [ ! -d ".test-runner" ]; then \ echo "Cloning containerized-test-runner-for-aws-lambda..."; \ - git clone --quiet https://github.com/aws/containerized-test-runner-for-aws-lambda.git .test-runner; \ + git clone --quiet --branch $(TEST_RUNNER_BRANCH) https://github.com/aws/containerized-test-runner-for-aws-lambda.git .test-runner; \ fi @echo "Building test runner Docker image..." @docker build -t test-runner:local -f .test-runner/Dockerfile .test-runner - + +test-dockerized-concurrent: build-test-runner + @echo "Running concurrent scenarios in Docker..." + @docker network create concurrent-test-net 2>/dev/null || true + @docker run --rm \ + --network concurrent-test-net \ + -e INPUT_SUITE_FILE_ARRAY='[]' \ + -e INPUT_SCENARIO_DIR=/workspace/test/dockerized/scenarios \ + -e DOCKER_IMAGE_NAME=local/test-base \ + -e TASK_FOLDER=./test/dockerized/tasks \ + -e GITHUB_WORKSPACE=/workspace \ + -e DOCKER_SHARED_NETWORK=concurrent-test-net \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -v "$(CURDIR):/workspace" \ + -w /workspace \ + test-runner:local + @docker network rm --force concurrent-test-net 2>/dev/null || true + +test-dockerized: build-test-runner @echo "Running tests in Docker..." @docker run --rm \ -e INPUT_SUITE_FILE_ARRAY='["./test/dockerized/suites/*.json"]' \ @@ -179,6 +196,7 @@ help: ## Show this help message @echo ' Usage: HANDLERS_TO_BUILD="basic-lambda" HANDLER="basic-lambda" make test-rie' @echo ' test-rie-lmi Test RIE in Lambda Managed Instance mode' @echo ' Usage: RIE_MAX_CONCURRENCY=4 HANDLERS_TO_BUILD="basic-lambda-concurrent" make test-rie-lmi' + @echo ' test-dockerized-concurrent Run concurrent LMI test scenarios' @echo ' test-dockerized Run dockerized test harness' @echo ' nuke Kill all running Docker containers' @echo '' diff --git a/examples/basic-lambda-concurrent/src/main.rs b/examples/basic-lambda-concurrent/src/main.rs index b32d65da1..0b106b961 100644 --- a/examples/basic-lambda-concurrent/src/main.rs +++ b/examples/basic-lambda-concurrent/src/main.rs @@ -1,32 +1,46 @@ // This example requires the following input to succeed: // { "command": "do something" } -use lambda_runtime::{service_fn, tracing, Error, LambdaEvent}; +use lambda_runtime::{service_fn, tracing, Diagnostic, Error, LambdaEvent}; use serde::{Deserialize, Serialize}; -/// This is also a made-up example. Requests come into the runtime as unicode -/// strings in json format, which can map to any structure that implements `serde::Deserialize` -/// The runtime pays no attention to the contents of the request payload. #[derive(Deserialize)] struct Request { command: String, } -/// This is a made-up example of what a response structure may look like. -/// There is no restriction on what it can be. The runtime requires responses -/// to be serialized into json. The runtime pays no attention -/// to the contents of the response payload. #[derive(Serialize)] struct Response { req_id: String, msg: String, } +#[derive(Debug)] +struct HandlerError(String); + +impl std::fmt::Display for HandlerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for Diagnostic { + fn from(e: HandlerError) -> Diagnostic { + Diagnostic { + error_type: "HandlerError".into(), + error_message: e.0, + } + } +} + #[tokio::main] async fn main() -> Result<(), Error> { // required to enable CloudWatch error logging by the runtime tracing::init_default_subscriber(); + let max_concurrency = std::env::var("AWS_LAMBDA_MAX_CONCURRENCY").unwrap_or_else(|_| "not set".to_string()); + tracing::info!(AWS_LAMBDA_MAX_CONCURRENCY = %max_concurrency, "starting concurrent handler"); + let func = service_fn(my_handler); if let Err(err) = lambda_runtime::run_concurrent(func).await { tracing::error!(error = %err, "run error"); @@ -35,17 +49,18 @@ async fn main() -> Result<(), Error> { Ok(()) } -pub(crate) async fn my_handler(event: LambdaEvent) -> Result { - // extract some useful info from the request +pub(crate) async fn my_handler(event: LambdaEvent) -> Result { let command = event.payload.command; - // prepare the response + if command == "fail" { + return Err(HandlerError("simulated handler error".into())); + } + let resp = Response { req_id: event.context.request_id, msg: format!("Command {command} executed."), }; - // return `Response` (it will be serialized to JSON automatically by the runtime) Ok(resp) } diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 000000000..ae40a9f1a --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,25 @@ +#!/bin/sh +# Pre-commit hook to run cargo fmt +# +# To install this hook, run: +# cp scripts/pre-commit .git/hooks/pre-commit +# chmod +x .git/hooks/pre-commit + +echo "Running cargo fmt..." + +# Run cargo fmt on the entire workspace +cargo fmt --all -- --check + +# Check if cargo fmt found any formatting issues +if [ $? -ne 0 ]; then + echo "" + echo "❌ Code formatting issues detected!" + echo "Running 'cargo fmt --all' to fix formatting..." + cargo fmt --all + echo "" + echo "✅ Formatting applied. Please review the changes and commit again." + exit 1 +fi + +echo "✅ Code formatting is correct!" +exit 0 diff --git a/test/dockerized/scenarios/concurrent_scenarios.py b/test/dockerized/scenarios/concurrent_scenarios.py new file mode 100644 index 000000000..8c2ce842b --- /dev/null +++ b/test/dockerized/scenarios/concurrent_scenarios.py @@ -0,0 +1,64 @@ +""" +Multi-concurrency test scenarios for basic-lambda-concurrent. + +The handler expects: { "command": "" } +and responds with: { "req_id": "", "msg": "Command executed." } +""" + +import os +from containerized_test_runner.models import Request, ConcurrentTest + +HANDLER = "basic-lambda-concurrent" +IMAGE = os.environ.get("TEST_IMAGE", "local/test-base") +DEFAULT_CONCURRENCY = 10 + + +def _make_env(concurrency: int = DEFAULT_CONCURRENCY) -> dict: + return { + "_HANDLER": HANDLER, + "AWS_LAMBDA_MAX_CONCURRENCY": str(concurrency), + "AWS_LAMBDA_LOG_FORMAT": "JSON", + } + + +def get_concurrent_scenarios(): + scenarios = [] + + # Happy path: DEFAULT_CONCURRENCY unique commands all succeed concurrently + batch = [ + Request( + payload={"command": f"task-{i}"}, + assertions=[{"transform": "{msg: .msg}", "response": {"msg": f"Command task-{i} executed."}}], + ) + for i in range(DEFAULT_CONCURRENCY) + ] + scenarios.append(ConcurrentTest( + name="concurrent_happy_path", + handler=HANDLER, + environment_variables=_make_env(), + request_batches=[batch], + image=IMAGE, + )) + + # Error isolation: N-1 failing requests + 1 valid — the valid one must still succeed + mixed_batch = [ + Request( + payload={"command": "fail"}, + assertions=[{"errorType": "HandlerError"}], + ) + for _ in range(DEFAULT_CONCURRENCY - 1) + ] + [ + Request( + payload={"command": "survivor"}, + assertions=[{"transform": "{msg: .msg}", "response": {"msg": "Command survivor executed."}}], + ) + ] + scenarios.append(ConcurrentTest( + name="concurrent_error_isolation", + handler=HANDLER, + environment_variables=_make_env(), + request_batches=[mixed_batch], + image=IMAGE, + )) + + return scenarios diff --git a/test/dockerized/suites/core.json b/test/dockerized/suites/core.json index 5a21d0665..d5d56ce7d 100644 --- a/test/dockerized/suites/core.json +++ b/test/dockerized/suites/core.json @@ -14,6 +14,77 @@ "transform": "{msg: .msg}" } ] + }, + { + "name": "test_basic_sqs", + "handler": "basic-sqs", + "request": { + "Records": [ + { + "messageId": "msg-001", + "receiptHandle": "receipt-001", + "body": "{\"id\":\"user-123\",\"text\":\"Hello from SQS\"}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1234567890", + "SenderId": "AIDAI123456789", + "ApproximateFirstReceiveTimestamp": "1234567890" + }, + "messageAttributes": {}, + "md5OfBody": "abc123", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-1:123456789012:test-queue", + "awsRegion": "us-east-1" + } + ] + }, + "assertions": [ + { + "response": null + } + ] + }, + { + "name": "test_http_basic_lambda", + "handler": "http-basic-lambda", + "request": { + "version": "2.0", + "routeKey": "$default", + "rawPath": "/", + "rawQueryString": "", + "headers": { + "accept": "text/html", + "user-agent": "test-client" + }, + "requestContext": { + "accountId": "123456789012", + "apiId": "api-id", + "domainName": "example.com", + "domainPrefix": "api", + "http": { + "method": "GET", + "path": "/", + "protocol": "HTTP/1.1", + "sourceIp": "127.0.0.1", + "userAgent": "test-client" + }, + "requestId": "req-001", + "routeKey": "$default", + "stage": "$default", + "time": "01/Jan/2024:00:00:00 +0000", + "timeEpoch": 1704067200000 + }, + "isBase64Encoded": false + }, + "assertions": [ + { + "response": { + "statusCode": 200, + "body": "Hello AWS Lambda HTTP request" + }, + "transform": "{statusCode: .statusCode, body: .body}" + } + ] } ] }