forked from aws/aws-lambda-runtime-interface-emulator
-
Notifications
You must be signed in to change notification settings - Fork 3
fix(ls-api): align mock with LocalStack API and add regression test #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
joe4dev
wants to merge
12
commits into
localstack
Choose a base branch
from
localstack-api-compat-test
base: localstack
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
9fa2b7f
fix(ls-api): align mock with LocalStack API contract and add regressi…
joe4dev 49f4ff9
fix(regression): move LS<->RIE API contract tests to production code
joe4dev 8e61b42
docs(ls-api): add README explaining how to use the LocalStack endpoin…
joe4dev cee7521
docs(ls-api): add Makefile and handler.py for running mock + RIE on m…
joe4dev 750ec5c
fix(ls-api): add missing AWS_REGION env var to start-rie
joe4dev c9867ca
feat(ls-api): add make test and make fail targets for trigger endpoints
joe4dev 1b429fb
fix(ls-api): downgrade log.Fatal to log.Error in debug endpoints, add…
joe4dev 45a99fe
feat(ls-api): add automated e2e smoke test with success + error invoc…
joe4dev a90c503
fix(ls-api): use host.docker.internal on macOS, improve smoke test de…
joe4dev 785d93e
fix(ls-api): use --add-host instead of platform-specific host resolution
joe4dev c34792f
fix(ls-api): fix smoke test on Apple Silicon, improve container debug…
joe4dev 73001c5
fix(ls-api): fix AWS_LAMBDA_FUNCTION_VERSION panic, deduplicate docke…
joe4dev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| name: LocalStack Smoke Tests | ||
|
|
||
| on: | ||
| push: | ||
| branches: [localstack] | ||
| pull_request: | ||
| branches: [localstack] | ||
|
|
||
| concurrency: | ||
| group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} | ||
| cancel-in-progress: true | ||
|
|
||
| jobs: | ||
| smoke-ls-api: | ||
| name: RIE ↔ LocalStack API Smoke Test | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - name: Set up Go | ||
| uses: actions/setup-go@v6 | ||
| with: | ||
| go-version-file: go.mod | ||
|
|
||
| - name: Run smoke test | ||
| run: make -C cmd/ls-api smoke-test |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "io" | ||
| "net/http" | ||
| "net/http/httptest" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| // --- JSON contract tests --- | ||
|
|
||
| // TestInvokeRequestContract verifies that InvokeRequest correctly maps the JSON field names | ||
| // that LocalStack sends to the RIE's /invoke endpoint (defined in | ||
| // localstack-pro/localstack-core/localstack/services/lambda_/invocation/execution_environment.py). | ||
| // | ||
| // WARNING: The LocalStack↔RIE API contract is currently unversioned. Any change to these | ||
| // field names is a silent breaking change that requires a coordinated update of both | ||
| // localstack-pro and lambda-runtime-init with no safe rollback path. | ||
| func TestInvokeRequestContract(t *testing.T) { | ||
| raw := `{ | ||
| "invoke-id": "abc-123", | ||
| "invoked-function-arn": "arn:aws:lambda:us-east-1:000000000000:function:my-fn", | ||
| "payload": "{\"key\":\"value\"}", | ||
| "trace-id": "Root=1-abc;Parent=def;Sampled=1" | ||
| }` | ||
|
|
||
| var req InvokeRequest | ||
| require.NoError(t, json.Unmarshal([]byte(raw), &req)) | ||
|
|
||
| assert.Equal(t, "abc-123", req.InvokeId) | ||
| assert.Equal(t, "arn:aws:lambda:us-east-1:000000000000:function:my-fn", req.InvokedFunctionArn) | ||
| assert.Equal(t, `{"key":"value"}`, req.Payload) | ||
| assert.Equal(t, "Root=1-abc;Parent=def;Sampled=1", req.TraceId) | ||
| } | ||
|
|
||
| // TestLogResponseContract verifies that LogResponse uses the "logs" JSON key expected by | ||
| // LocalStack's invocation_logs handler (executor_endpoint.py). | ||
| func TestLogResponseContract(t *testing.T) { | ||
| raw := `{"logs":"START RequestId: abc\nEND RequestId: abc\n"}` | ||
|
|
||
| var lr LogResponse | ||
| require.NoError(t, json.Unmarshal([]byte(raw), &lr)) | ||
|
|
||
| assert.Equal(t, "START RequestId: abc\nEND RequestId: abc\n", lr.Logs) | ||
| } | ||
|
|
||
| // --- LocalStackAdapter.SendStatus tests --- | ||
|
|
||
| func TestSendStatus_ReadySendsToCorrectPath(t *testing.T) { | ||
| var capturedReq *http.Request | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| capturedReq = r | ||
| w.WriteHeader(http.StatusAccepted) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL, RuntimeId: "runtime-abc"} | ||
| require.NoError(t, adapter.SendStatus(Ready, []byte{})) | ||
|
|
||
| assert.Equal(t, http.MethodPost, capturedReq.Method) | ||
| assert.Equal(t, "/status/runtime-abc/ready", capturedReq.URL.Path) | ||
| } | ||
|
|
||
| func TestSendStatus_ErrorSendsToCorrectPath(t *testing.T) { | ||
| var capturedReq *http.Request | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| capturedReq = r | ||
| w.WriteHeader(http.StatusAccepted) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL, RuntimeId: "runtime-abc"} | ||
| require.NoError(t, adapter.SendStatus(Error, []byte(`{"errorMessage":"init failed"}`))) | ||
|
|
||
| assert.Equal(t, http.MethodPost, capturedReq.Method) | ||
| assert.Equal(t, "/status/runtime-abc/error", capturedReq.URL.Path) | ||
| } | ||
|
|
||
| // --- LocalStackAdapter.SendLogs tests --- | ||
|
|
||
| func TestSendLogs_SendsJSONWithLogsKey(t *testing.T) { | ||
| var capturedPath string | ||
| var capturedBody LogResponse | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| capturedPath = r.URL.Path | ||
| body, _ := io.ReadAll(r.Body) | ||
| _ = json.Unmarshal(body, &capturedBody) | ||
| w.WriteHeader(http.StatusAccepted) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL} | ||
| logs := LogResponse{Logs: "START RequestId: invoke-1\nEND RequestId: invoke-1\n"} | ||
| require.NoError(t, adapter.SendLogs("invoke-1", logs)) | ||
|
|
||
| assert.Equal(t, "/invocations/invoke-1/logs", capturedPath) | ||
| assert.Equal(t, logs.Logs, capturedBody.Logs) | ||
| } | ||
|
|
||
| // --- LocalStackAdapter.SendResult routing tests --- | ||
|
|
||
| func TestSendResult_SuccessGoesToResponseEndpoint(t *testing.T) { | ||
| var capturedPath string | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| capturedPath = r.URL.Path | ||
| w.WriteHeader(http.StatusAccepted) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL} | ||
| require.NoError(t, adapter.SendResult("invoke-1", []byte(`{"result":"ok"}`), false)) | ||
|
|
||
| assert.Equal(t, "/invocations/invoke-1/response", capturedPath) | ||
| } | ||
|
|
||
| func TestSendResult_ErrorBodyGoesToErrorEndpoint(t *testing.T) { | ||
| var capturedPath string | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| capturedPath = r.URL.Path | ||
| w.WriteHeader(http.StatusAccepted) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| // Body contains "errorType" — LocalStack distinguishes function errors this way | ||
| adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL} | ||
| errBody := []byte(`{"errorMessage":"something went wrong","errorType":"RuntimeError"}`) | ||
| require.NoError(t, adapter.SendResult("invoke-1", errBody, false)) | ||
|
|
||
| assert.Equal(t, "/invocations/invoke-1/error", capturedPath) | ||
| } | ||
|
|
||
| func TestSendResult_ExplicitErrorFlagGoesToErrorEndpoint(t *testing.T) { | ||
| var capturedPath string | ||
| srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| capturedPath = r.URL.Path | ||
| w.WriteHeader(http.StatusAccepted) | ||
| })) | ||
| defer srv.Close() | ||
|
|
||
| // isError=true covers cases like timeout where the RIE itself constructs the error body | ||
| adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL} | ||
| require.NoError(t, adapter.SendResult("invoke-1", []byte(`{"errorMessage":"Task timed out"}`), true)) | ||
|
|
||
| assert.Equal(t, "/invocations/invoke-1/error", capturedPath) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| THIS_MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) | ||
| REPO_ROOT := $(abspath $(THIS_MAKEFILE_DIR)/../..) | ||
|
|
||
| ARCH ?= x86_64 | ||
| MOCK_PORT := 48490 | ||
| INTEROP_PORT := 9563 | ||
| RIE_BINARY := $(REPO_ROOT)/bin/aws-lambda-rie-$(ARCH) | ||
| LS_API_BIN := $(REPO_ROOT)/bin/ls-api | ||
|
|
||
| # Common docker flags for start-rie and start-rie-detached. | ||
| # Uses deferred assignment (=) so $$LATEST is not expanded until recipe time, | ||
| # where Make reduces $$ -> $ before passing the line to the shell. | ||
| RIE_DOCKER_OPTS = \ | ||
| --platform linux/amd64 \ | ||
| --add-host=host.docker.internal:host-gateway \ | ||
| -p $(INTEROP_PORT):$(INTEROP_PORT) \ | ||
| -v $(RIE_BINARY):/var/rapid/init:ro \ | ||
| -v $(THIS_MAKEFILE_DIR)/handler.py:/var/task/handler.py:ro \ | ||
| -e LOCALSTACK_RUNTIME_ENDPOINT=http://host.docker.internal:$(MOCK_PORT) \ | ||
| -e LOCALSTACK_RUNTIME_ID=test-runtime-id \ | ||
| -e AWS_LAMBDA_FUNCTION_TIMEOUT=30 \ | ||
| -e AWS_LAMBDA_FUNCTION_VERSION='$$LATEST' \ | ||
| -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 \ | ||
| -e AWS_REGION=us-east-1 \ | ||
| -e _HANDLER=handler.handler \ | ||
| --entrypoint /var/rapid/init | ||
|
|
||
| .PHONY: build-rie build-ls-api start-mock start-rie start-rie-detached success fail smoke-test | ||
|
|
||
| build-rie: ## Build the RIE Linux binary via Go cross-compilation (works on macOS) | ||
| $(MAKE) -C $(REPO_ROOT) ARCH=$(ARCH) compile-lambda-linux | ||
|
|
||
| build-ls-api: ## Build the ls-api mock binary | ||
| go build -o $(LS_API_BIN) $(THIS_MAKEFILE_DIR) | ||
|
|
||
| start-mock: ## Run the ls-api LocalStack endpoint mock natively (no Docker needed) | ||
| go run $(THIS_MAKEFILE_DIR) | ||
|
|
||
| start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda container | ||
| docker run --rm $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12 | ||
|
|
||
| start-rie-detached: ## Start the RIE in detached mode; prints container ID (binaries must be pre-built) | ||
| @docker run --detach $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12 | ||
|
|
||
| success: ## Trigger a successful invocation via the mock's /success endpoint | ||
| curl -sf http://localhost:$(MOCK_PORT)/success | ||
|
|
||
| fail: ## Trigger an error invocation via the mock's /fail endpoint | ||
| curl -sf http://localhost:$(MOCK_PORT)/fail | ||
|
|
||
| smoke-test: build-rie build-ls-api ## Full e2e smoke test: start mock + RIE, verify success + error invocations, cleanup | ||
| $(THIS_MAKEFILE_DIR)/smoke-test.sh |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| # ls-api — LocalStack endpoint mock | ||
|
|
||
| A lightweight HTTP server that stands in for the LocalStack endpoint when testing the RIE in isolation, without a running LocalStack instance. | ||
|
|
||
| ## Ports | ||
|
|
||
| | Port | Direction | Purpose | | ||
| |------|-----------|---------| | ||
| | `48490` | inbound | Receives callbacks from the RIE (logs, response, status) | | ||
| | `9563` | outbound | Sends invocations to the RIE's `/invoke` endpoint; must be **exposed** when the RIE runs in Docker | | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Go toolchain — to run the mock | ||
| - Docker Desktop — to run the RIE (the binary targets Linux) | ||
|
|
||
| ## How to use | ||
|
|
||
| **Terminal 1 — start the mock:** | ||
|
|
||
| ```bash | ||
| make start-mock | ||
| ``` | ||
|
|
||
| **Terminal 2 — build and start the RIE** pointing at the mock: | ||
|
|
||
| ```bash | ||
| make start-rie | ||
| ``` | ||
|
|
||
| This cross-compiles the RIE for Linux and runs it inside a `public.ecr.aws/lambda/python:3.12` container using `handler.py` as the Lambda function. Port `9563` is exposed so the mock can deliver invocations. Once the RIE sends `POST /status/{id}/ready`, the mock automatically fires one invocation with `{"counter": 0}` and logs the result. | ||
|
|
||
| To build for ARM (e.g. Apple Silicon): | ||
|
|
||
| ```bash | ||
| make start-rie ARCH=arm64 | ||
| ``` | ||
|
|
||
| ## Trigger endpoints | ||
|
|
||
| Two helper endpoints let you fire additional invocations manually after startup: | ||
|
|
||
| | Endpoint | Payload | | ||
| |----------|---------| | ||
| | `GET /success` | `{"counter": 0}` — expects a successful response | | ||
| | `GET /fail` | `{"counter": 0, "fail": "yes"}` — expects an error response | | ||
|
|
||
| ```bash | ||
| make success | ||
| make fail | ||
| ``` | ||
|
|
||
| All RIE callbacks (`/invocations/*/response`, `/invocations/*/error`, `/invocations/*/logs`, `/status/*/*`) are logged to stdout and return `202 Accepted`. | ||
|
|
||
| ## Automated smoke test | ||
|
|
||
| To run the full e2e smoke test non-interactively (used in CI): | ||
|
|
||
| ```bash | ||
| make smoke-test | ||
| ``` | ||
|
|
||
| This builds both the RIE binary and the ls-api mock, starts them, verifies a successful and a failing invocation, then cleans up. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| import json | ||
|
|
||
|
|
||
| def handler(event, context): | ||
| print(f"Received: {json.dumps(event)}") | ||
| if event.get("fail"): | ||
| raise Exception(f"Intentional failure: fail={event['fail']}") | ||
| return {"statusCode": 200, "body": json.dumps({"echo": event})} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are the main refactoring changes to LS RIE production code. Extracting
SendLogsandSendResultis needed for testing.