From 9fa2b7f605bd4792d6fcd07f5c6f4386238eea4f Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Tue, 2 Jun 2026 17:31:03 +0200 Subject: [PATCH 01/15] fix(ls-api): align mock with LocalStack API contract and add regression tests - Return 202 Accepted from all handlers to match executor_endpoint.py - Add missing InvokedFunctionArn and TraceId fields to InvokeRequest - Parse {"logs":"..."} JSON in invokeLogsHandler instead of raw bytes - Read and log request body in invokeErrorHandler - Downgrade log.Fatal to log.Error in statusHandler goroutine - Add main_test.go with 7 regression tests covering all endpoints, JSON field names, and the async invoke triggered on status/ready Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/main.go | 32 ++++++-- cmd/ls-api/main_test.go | 174 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 7 deletions(-) create mode 100644 cmd/ls-api/main_test.go diff --git a/cmd/ls-api/main.go b/cmd/ls-api/main.go index bd979af7..89c175b0 100644 --- a/cmd/ls-api/main.go +++ b/cmd/ls-api/main.go @@ -66,16 +66,26 @@ func main() { func invokeLogsHandler(w http.ResponseWriter, r *http.Request) { invokeId := chi.URLParam(r, "invoke_id") log.Println(invokeId) - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - log.Error(err) + var logResponse LogResponse + if err := json.NewDecoder(r.Body).Decode(&logResponse); err != nil { + log.Error("invalid logs payload: ", err) + } else { + log.Println("log result: " + logResponse.Logs) } - log.Println("log result: " + string(bodyBytes)) + w.WriteHeader(http.StatusAccepted) } +// InvokeRequest is sent by LocalStack to trigger an invocation. type InvokeRequest struct { - InvokeId string `json:"invoke-id"` - Payload string `json:"payload"` + InvokeId string `json:"invoke-id"` + InvokedFunctionArn string `json:"invoked-function-arn"` + Payload string `json:"payload"` + TraceId string `json:"trace-id"` +} + +// LogResponse is sent by the runtime to report logs for a completed invocation. +type LogResponse struct { + Logs string `json:"logs"` } func statusHandler(w http.ResponseWriter, r *http.Request) { @@ -87,10 +97,11 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: "12345", Payload: "{\"counter\":0}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { - log.Fatal(err) + log.Error(err) } }() } + w.WriteHeader(http.StatusAccepted) } func invokeResponseHandler(w http.ResponseWriter, r *http.Request) { @@ -101,9 +112,16 @@ func invokeResponseHandler(w http.ResponseWriter, r *http.Request) { log.Error(err) } log.Println("result: " + string(bodyBytes)) + w.WriteHeader(http.StatusAccepted) } func invokeErrorHandler(w http.ResponseWriter, r *http.Request) { invokeId := chi.URLParam(r, "invoke_id") log.Println(invokeId) + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + log.Error(err) + } + log.Println("error result: " + string(bodyBytes)) + w.WriteHeader(http.StatusAccepted) } diff --git a/cmd/ls-api/main_test.go b/cmd/ls-api/main_test.go new file mode 100644 index 00000000..45ff012e --- /dev/null +++ b/cmd/ls-api/main_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testInvokeID = "test-invoke-id-12345" + +// newTestRouter creates a chi router with the same LocalStack API routes as main(), +// without the debug /test and /fail endpoints. +func newTestRouter() *chi.Mux { + r := chi.NewRouter() + r.Post("/invocations/{invoke_id}/response", invokeResponseHandler) + r.Post("/invocations/{invoke_id}/error", invokeErrorHandler) + r.Post("/invocations/{invoke_id}/logs", invokeLogsHandler) + r.Post("/status/{runtime_id}/{status}", statusHandler) + return r +} + +// TestInvocationResponseReturns202 verifies POST /invocations/{id}/response returns 202 Accepted. +// LocalStack's executor_endpoint.py invocation_response returns HTTPStatus.ACCEPTED. +func TestInvocationResponseReturns202(t *testing.T) { + srv := httptest.NewServer(newTestRouter()) + defer srv.Close() + + resp, err := http.Post( + srv.URL+"/invocations/"+testInvokeID+"/response", + "application/json", + bytes.NewBufferString(`{"result":"ok"}`), + ) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +// TestInvocationErrorReturns202 verifies POST /invocations/{id}/error returns 202 Accepted. +// LocalStack's executor_endpoint.py invocation_error returns HTTPStatus.ACCEPTED. +func TestInvocationErrorReturns202(t *testing.T) { + srv := httptest.NewServer(newTestRouter()) + defer srv.Close() + + body := `{"errorMessage":"something went wrong","errorType":"RuntimeError","stackTrace":[]}` + resp, err := http.Post( + srv.URL+"/invocations/"+testInvokeID+"/error", + "application/json", + bytes.NewBufferString(body), + ) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +// TestInvocationLogsReturns202 verifies POST /invocations/{id}/logs returns 202 Accepted +// and accepts a {"logs":"..."} JSON body as sent by custom_interop.go via LogResponse. +func TestInvocationLogsReturns202(t *testing.T) { + srv := httptest.NewServer(newTestRouter()) + defer srv.Close() + + logPayload, err := json.Marshal(LogResponse{ + Logs: "START RequestId: " + testInvokeID + " Version: $LATEST\nEND RequestId: " + testInvokeID + "\n", + }) + require.NoError(t, err) + + resp, err := http.Post( + srv.URL+"/invocations/"+testInvokeID+"/logs", + "application/json", + bytes.NewReader(logPayload), + ) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +// TestStatusReadyReturns202AndTriggersInvoke verifies that POST /status/{runtime_id}/ready: +// - returns 202 Accepted (matching LocalStack executor_endpoint.py status_ready) +// - asynchronously sends a POST to the invoke endpoint with a valid InvokeRequest body +func TestStatusReadyReturns202AndTriggersInvoke(t *testing.T) { + invokeCh := make(chan InvokeRequest, 1) + captureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req InvokeRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + invokeCh <- req + w.WriteHeader(http.StatusOK) + })) + defer captureServer.Close() + + origInvokeUrl := invokeUrl + invokeUrl = captureServer.URL + "/invoke" + defer func() { invokeUrl = origInvokeUrl }() + + srv := httptest.NewServer(newTestRouter()) + defer srv.Close() + + resp, err := http.Post( + srv.URL+"/status/runtime-id-123/ready", + "application/json", + bytes.NewBufferString(""), + ) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + + select { + case req := <-invokeCh: + assert.NotEmpty(t, req.InvokeId, "invoke-id must be set in the triggered InvokeRequest") + assert.NotEmpty(t, req.Payload, "payload must be set in the triggered InvokeRequest") + case <-time.After(2 * time.Second): + t.Error("timed out waiting for invoke request to be sent after status/ready") + } +} + +// TestStatusErrorReturns202 verifies POST /status/{runtime_id}/error returns 202 Accepted. +// LocalStack's executor_endpoint.py status_error returns HTTPStatus.ACCEPTED on the first call. +func TestStatusErrorReturns202(t *testing.T) { + srv := httptest.NewServer(newTestRouter()) + defer srv.Close() + + resp, err := http.Post( + srv.URL+"/status/runtime-id-123/error", + "application/json", + bytes.NewBufferString(`{"errorMessage":"init failed","errorType":"InitError"}`), + ) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusAccepted, resp.StatusCode) +} + +// TestInvokeRequestJSONFieldNames verifies that InvokeRequest uses the exact JSON field names +// that LocalStack sends to the runtime's /invoke endpoint (as defined in custom_interop.go). +// +// 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 TestInvokeRequestJSONFieldNames(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) +} + +// TestLogResponseJSONFieldName verifies that LogResponse uses the "logs" key +// expected by LocalStack's executor_endpoint.py invocation_logs handler. +func TestLogResponseJSONFieldName(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) +} From 49f4ff96e9ae2f1abc275a78d68dd7c8ba80c3ae Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 13:40:27 +0200 Subject: [PATCH 02/15] fix(regression): move LS<->RIE API contract tests to production code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous tests only exercised cmd/ls-api (a manual testing tool), so a change to the actual RIE production code in cmd/localstack would not have been caught. - Extract SendLogs and SendResult from the inline goroutine into LocalStackAdapter methods, making the API call sites testable - Add cmd/localstack/custom_interop_test.go with 8 contract tests that drive the production types and methods directly: InvokeRequest/LogResponse JSON field names, SendStatus URL routing, SendLogs format, SendResult response-vs-error routing - Remove the two misleading JSON contract tests from cmd/ls-api — they tested a separate struct copy and gave false confidence Co-Authored-By: Claude Sonnet 4.6 --- cmd/localstack/custom_interop.go | 59 +++++----- cmd/localstack/custom_interop_test.go | 149 ++++++++++++++++++++++++++ cmd/ls-api/main_test.go | 37 +------ 3 files changed, 188 insertions(+), 57 deletions(-) create mode 100644 cmd/localstack/custom_interop_test.go diff --git a/cmd/localstack/custom_interop.go b/cmd/localstack/custom_interop.go index e33aced1..6b89d656 100644 --- a/cmd/localstack/custom_interop.go +++ b/cmd/localstack/custom_interop.go @@ -50,6 +50,37 @@ func (l *LocalStackAdapter) SendStatus(status LocalStackStatus, payload []byte) return nil } +// SendLogs posts the captured invocation logs to LocalStack. +func (l *LocalStackAdapter) SendLogs(invokeId string, logs LogResponse) error { + serialized, err := json.Marshal(logs) + if err != nil { + return err + } + _, err = http.Post(l.UpstreamEndpoint+"/invocations/"+invokeId+"/logs", "application/json", bytes.NewReader(serialized)) + return err +} + +// SendResult posts the invocation result body to LocalStack. +// If isError is false, the body is also inspected for an "errorType" field — its +// presence indicates a Lambda function error and routes the result to /error. +func (l *LocalStackAdapter) SendResult(invokeId string, body []byte, isError bool) error { + if !isError { + var fields map[string]any + if json.Unmarshal(body, &fields) == nil { + _, isError = fields["errorType"] + } + } + endpoint := "/invocations/" + invokeId + "/response" + if isError { + log.Infoln("Sending to /error") + endpoint = "/invocations/" + invokeId + "/error" + } else { + log.Infoln("Sending to /response") + } + _, err := http.Post(l.UpstreamEndpoint+endpoint, "application/json", bytes.NewReader(body)) + return err +} + // The InvokeRequest is sent by LocalStack to trigger an invocation type InvokeRequest struct { InvokeId string `json:"invoke-id"` @@ -157,31 +188,11 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto memorySize := GetEnvOrDie("AWS_LAMBDA_FUNCTION_MEMORY_SIZE") PrintEndReports(invokeR.InvokeId, "", memorySize, invokeStart, timeoutDuration, logCollector) - serializedLogs, err2 := json.Marshal(logCollector.getLogs()) - if err2 == nil { - _, err2 = http.Post(server.upstreamEndpoint+"/invocations/"+invokeR.InvokeId+"/logs", "application/json", bytes.NewReader(serializedLogs)) - // TODO: handle err + if err2 := server.localStackAdapter.SendLogs(invokeR.InvokeId, logCollector.getLogs()); err2 != nil { + log.Error("failed to send logs to LocalStack: ", err2) } - - var errR map[string]any - marshalErr := json.Unmarshal(invokeResp.Body, &errR) - - if !isErr && marshalErr == nil { - _, isErr = errR["errorType"] - } - - if isErr { - log.Infoln("Sending to /error") - _, err = http.Post(server.upstreamEndpoint+"/invocations/"+invokeR.InvokeId+"/error", "application/json", bytes.NewReader(invokeResp.Body)) - if err != nil { - log.Error(err) - } - } else { - log.Infoln("Sending to /response") - _, err = http.Post(server.upstreamEndpoint+"/invocations/"+invokeR.InvokeId+"/response", "application/json", bytes.NewReader(invokeResp.Body)) - if err != nil { - log.Error(err) - } + if err2 := server.localStackAdapter.SendResult(invokeR.InvokeId, invokeResp.Body, isErr); err2 != nil { + log.Error("failed to send result to LocalStack: ", err2) } }() diff --git a/cmd/localstack/custom_interop_test.go b/cmd/localstack/custom_interop_test.go new file mode 100644 index 00000000..2e54313e --- /dev/null +++ b/cmd/localstack/custom_interop_test.go @@ -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) +} diff --git a/cmd/ls-api/main_test.go b/cmd/ls-api/main_test.go index 45ff012e..96103ecc 100644 --- a/cmd/ls-api/main_test.go +++ b/cmd/ls-api/main_test.go @@ -13,6 +13,10 @@ import ( "github.com/stretchr/testify/require" ) +// These tests verify the ls-api mock server (cmd/ls-api) — a manual testing tool that +// emulates the LocalStack endpoint locally. They do NOT test the production RIE code. +// For regression tests of the actual LS↔RIE API contract, see cmd/localstack/custom_interop_test.go. + const testInvokeID = "test-invoke-id-12345" // newTestRouter creates a chi router with the same LocalStack API routes as main(), @@ -139,36 +143,3 @@ func TestStatusErrorReturns202(t *testing.T) { assert.Equal(t, http.StatusAccepted, resp.StatusCode) } -// TestInvokeRequestJSONFieldNames verifies that InvokeRequest uses the exact JSON field names -// that LocalStack sends to the runtime's /invoke endpoint (as defined in custom_interop.go). -// -// 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 TestInvokeRequestJSONFieldNames(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) -} - -// TestLogResponseJSONFieldName verifies that LogResponse uses the "logs" key -// expected by LocalStack's executor_endpoint.py invocation_logs handler. -func TestLogResponseJSONFieldName(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) -} From 8e61b42717ff4851fd1071847292da5e721e3160 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 14:22:03 +0200 Subject: [PATCH 03/15] docs(ls-api): add README explaining how to use the LocalStack endpoint mock Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/README.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 cmd/ls-api/README.md diff --git a/cmd/ls-api/README.md b/cmd/ls-api/README.md new file mode 100644 index 00000000..61be7d5e --- /dev/null +++ b/cmd/ls-api/README.md @@ -0,0 +1,44 @@ +# 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 | + +## How to use + +**Terminal 1 — start the mock:** + +```bash +go run ./cmd/ls-api +``` + +**Terminal 2 — start the RIE** pointing at the mock instead of LocalStack: + +```bash +LOCALSTACK_RUNTIME_ENDPOINT=http://localhost:48490 \ +LOCALSTACK_RUNTIME_ID=test-runtime-id \ +./bin/aws-lambda-rie-x86_64 python3 -m awslambdaric handler.handler +``` + +Once the RIE sends `POST /status/{id}/ready`, the mock automatically fires one invocation with `{"counter": 0}` and logs the result. + +## Trigger endpoints + +Two helper endpoints let you fire additional invocations manually after startup: + +| Endpoint | Payload | +|----------|---------| +| `GET /test` | `{"counter": 0}` — expects a successful response | +| `GET /fail` | `{"counter": 0, "fail": "yes"}` — expects an error response | + +```bash +curl http://localhost:48490/test +curl http://localhost:48490/fail +``` + +All RIE callbacks (`/invocations/*/response`, `/invocations/*/error`, `/invocations/*/logs`, `/status/*/*`) are logged to stdout and return `202 Accepted`. From cee752131dcce42761f5ad5c3563a943492cad3b Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 14:54:39 +0200 Subject: [PATCH 04/15] docs(ls-api): add Makefile and handler.py for running mock + RIE on macOS - Makefile: build-rie (Go cross-compilation), start-mock (native go run), start-rie (Docker; exposes port 9563 so host mock can reach RIE /invoke) - handler.py: minimal Python Lambda function used by start-rie - README: replace raw Linux binary instructions with make commands, add prerequisites section and ARCH=arm64 note Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/Makefile | 29 +++++++++++++++++++++++++++++ cmd/ls-api/README.md | 23 ++++++++++++++++------- cmd/ls-api/handler.py | 6 ++++++ 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 cmd/ls-api/Makefile create mode 100644 cmd/ls-api/handler.py diff --git a/cmd/ls-api/Makefile b/cmd/ls-api/Makefile new file mode 100644 index 00000000..68fb00f2 --- /dev/null +++ b/cmd/ls-api/Makefile @@ -0,0 +1,29 @@ +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) + +.PHONY: build-rie start-mock start-rie + +build-rie: ## Build the RIE Linux binary via Go cross-compilation (works on macOS) + $(MAKE) -C $(REPO_ROOT) ARCH=$(ARCH) compile-lambda-linux + +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 \ + -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 _HANDLER=handler.handler \ + --entrypoint /var/rapid/init \ + public.ecr.aws/lambda/python:3.12 diff --git a/cmd/ls-api/README.md b/cmd/ls-api/README.md index 61be7d5e..cd5e31ee 100644 --- a/cmd/ls-api/README.md +++ b/cmd/ls-api/README.md @@ -7,25 +7,34 @@ A lightweight HTTP server that stands in for the LocalStack endpoint when testin | Port | Direction | Purpose | |------|-----------|---------| | `48490` | inbound | Receives callbacks from the RIE (logs, response, status) | -| `9563` | outbound | Sends invocations to the RIE's `/invoke` endpoint | +| `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 -go run ./cmd/ls-api +make start-mock ``` -**Terminal 2 — start the RIE** pointing at the mock instead of LocalStack: +**Terminal 2 — build and start the RIE** pointing at the mock: ```bash -LOCALSTACK_RUNTIME_ENDPOINT=http://localhost:48490 \ -LOCALSTACK_RUNTIME_ID=test-runtime-id \ -./bin/aws-lambda-rie-x86_64 python3 -m awslambdaric handler.handler +make start-rie ``` -Once the RIE sends `POST /status/{id}/ready`, the mock automatically fires one invocation with `{"counter": 0}` and logs the result. +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 diff --git a/cmd/ls-api/handler.py b/cmd/ls-api/handler.py new file mode 100644 index 00000000..6e024755 --- /dev/null +++ b/cmd/ls-api/handler.py @@ -0,0 +1,6 @@ +import json + + +def handler(event, context): + print(f"Received: {json.dumps(event)}") + return {"statusCode": 200, "body": json.dumps({"echo": event})} From 750ec5ce099826ed4ff8e3195f90dffc679056c3 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 15:00:55 +0200 Subject: [PATCH 05/15] fix(ls-api): add missing AWS_REGION env var to start-rie xraydaemon.go calls GetEnvOrDie("AWS_REGION") unconditionally at startup regardless of whether X-Ray telemetry is enabled, causing an immediate panic. Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/ls-api/Makefile b/cmd/ls-api/Makefile index 68fb00f2..4c0565a2 100644 --- a/cmd/ls-api/Makefile +++ b/cmd/ls-api/Makefile @@ -24,6 +24,7 @@ start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda con -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 \ public.ecr.aws/lambda/python:3.12 From c9867ca565b20a6becf16a9eb0d8f0f5e0ab030c Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 15:04:06 +0200 Subject: [PATCH 06/15] feat(ls-api): add make test and make fail targets for trigger endpoints Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/Makefile | 8 +++++++- cmd/ls-api/README.md | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/ls-api/Makefile b/cmd/ls-api/Makefile index 4c0565a2..a0cc5a94 100644 --- a/cmd/ls-api/Makefile +++ b/cmd/ls-api/Makefile @@ -6,7 +6,7 @@ MOCK_PORT := 48490 INTEROP_PORT := 9563 RIE_BINARY := $(REPO_ROOT)/bin/aws-lambda-rie-$(ARCH) -.PHONY: build-rie start-mock start-rie +.PHONY: build-rie start-mock start-rie test fail build-rie: ## Build the RIE Linux binary via Go cross-compilation (works on macOS) $(MAKE) -C $(REPO_ROOT) ARCH=$(ARCH) compile-lambda-linux @@ -28,3 +28,9 @@ start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda con -e _HANDLER=handler.handler \ --entrypoint /var/rapid/init \ public.ecr.aws/lambda/python:3.12 + +test: ## Trigger a successful invocation via the mock's /test endpoint + curl -sf http://localhost:$(MOCK_PORT)/test + +fail: ## Trigger an error invocation via the mock's /fail endpoint + curl -sf http://localhost:$(MOCK_PORT)/fail diff --git a/cmd/ls-api/README.md b/cmd/ls-api/README.md index cd5e31ee..b0e11536 100644 --- a/cmd/ls-api/README.md +++ b/cmd/ls-api/README.md @@ -46,8 +46,8 @@ Two helper endpoints let you fire additional invocations manually after startup: | `GET /fail` | `{"counter": 0, "fail": "yes"}` — expects an error response | ```bash -curl http://localhost:48490/test -curl http://localhost:48490/fail +make test +make fail ``` All RIE callbacks (`/invocations/*/response`, `/invocations/*/error`, `/invocations/*/logs`, `/status/*/*`) are logged to stdout and return `202 Accepted`. From 1b429fb78b7aa83673a43e0ef556172b34b64bbb Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 15:09:13 +0200 Subject: [PATCH 07/15] fix(ls-api): downgrade log.Fatal to log.Error in debug endpoints, add startup log - /test and /fail endpoints now log errors instead of exiting on connection failure, consistent with the same fix applied earlier to statusHandler - Log the listen port on startup for easier debugging - Update README-LOCALSTACK.md: remove "likely outdated" label, link to ls-api README Co-Authored-By: Claude Sonnet 4.6 --- README-LOCALSTACK.md | 3 ++- cmd/ls-api/main.go | 5 +++-- cmd/ls-api/main_test.go | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README-LOCALSTACK.md b/README-LOCALSTACK.md index 27ef958e..1e8a9d72 100644 --- a/README-LOCALSTACK.md +++ b/README-LOCALSTACK.md @@ -18,7 +18,8 @@ Refer to [debugging/README.md](./debugging/README.md) for instructions on how to | `cmd/localstack` | LocalStack customizations | | ├── `main.go` | Main entrypoint | | ├── `custom_interop.go` | Custom server interface between the Lambda runtime API and this Go init. Implements the `Server` interface from `lambda/interop/model.go:Server` but forwards most calls to the original implementation in `lambda/rapidcore/server.go` available as `delegate`. | -| `cmd/ls-api` | Mock LocalStack component for testing (likely outdated) | +| `cmd/ls-api` | Mock LocalStack component for smoke testing | +| ├── [`README.md`](./cmd/ls-api/README.md) | Instructions for LS API<->RIE smoke testing | | `debugging/` | Debug and test this Go init with LocalStack | | ├── [`README.md`](./debugging/README.md) | Instructions for building and debugging with LocalStack | | `lambda` | Original AWS implementation of the runtime emulator ideally kept untouched | diff --git a/cmd/ls-api/main.go b/cmd/ls-api/main.go index 89c175b0..5890a132 100644 --- a/cmd/ls-api/main.go +++ b/cmd/ls-api/main.go @@ -33,7 +33,7 @@ func main() { invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { - log.Fatal(err) + log.Error(err) } w.WriteHeader(200) @@ -47,7 +47,7 @@ func main() { invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0, \"fail\": \"yes\"}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { - log.Fatal(err) + log.Error(err) } w.WriteHeader(200) @@ -57,6 +57,7 @@ func main() { } }) + log.Infof("Listening on port :%d", listenPort) err := http.ListenAndServe(fmt.Sprintf(":%d", listenPort), router) if err != nil { log.Fatal(err) diff --git a/cmd/ls-api/main_test.go b/cmd/ls-api/main_test.go index 96103ecc..30bbac76 100644 --- a/cmd/ls-api/main_test.go +++ b/cmd/ls-api/main_test.go @@ -142,4 +142,3 @@ func TestStatusErrorReturns202(t *testing.T) { assert.Equal(t, http.StatusAccepted, resp.StatusCode) } - From 45a99fe3df54aeaa14bad272055b6b3fbb526145 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 16:17:42 +0200 Subject: [PATCH 08/15] feat(ls-api): add automated e2e smoke test with success + error invocation Adds a smoke-test.sh script and Makefile target that build the RIE and ls-api mock, run both, verify a successful and a failing Lambda invocation against the mock endpoint, then clean up. Used in the new ls-smoke-tests CI workflow (.github/workflows/ls-smoke-tests.yml). Also renames the /test trigger endpoint to /success, adds structured logging to invokeResponseHandler/invokeErrorHandler for reliable grepping, updates handler.py to raise on {"fail": ...} so the error path is exercised, and builds both binaries into bin/. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ls-smoke-tests.yml | 26 +++++++++ cmd/ls-api/Makefile | 13 +++-- cmd/ls-api/README.md | 14 ++++- cmd/ls-api/handler.py | 2 + cmd/ls-api/main.go | 8 ++- cmd/ls-api/main_test.go | 2 +- cmd/ls-api/smoke-test.sh | 80 ++++++++++++++++++++++++++++ 7 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ls-smoke-tests.yml create mode 100755 cmd/ls-api/smoke-test.sh diff --git a/.github/workflows/ls-smoke-tests.yml b/.github/workflows/ls-smoke-tests.yml new file mode 100644 index 00000000..50ea67bc --- /dev/null +++ b/.github/workflows/ls-smoke-tests.yml @@ -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 diff --git a/cmd/ls-api/Makefile b/cmd/ls-api/Makefile index a0cc5a94..3ed21476 100644 --- a/cmd/ls-api/Makefile +++ b/cmd/ls-api/Makefile @@ -5,12 +5,16 @@ 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 -.PHONY: build-rie start-mock start-rie test fail +.PHONY: build-rie build-ls-api start-mock start-rie 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) @@ -29,8 +33,11 @@ start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda con --entrypoint /var/rapid/init \ public.ecr.aws/lambda/python:3.12 -test: ## Trigger a successful invocation via the mock's /test endpoint - curl -sf http://localhost:$(MOCK_PORT)/test +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 diff --git a/cmd/ls-api/README.md b/cmd/ls-api/README.md index b0e11536..ff7a2fa4 100644 --- a/cmd/ls-api/README.md +++ b/cmd/ls-api/README.md @@ -42,12 +42,22 @@ Two helper endpoints let you fire additional invocations manually after startup: | Endpoint | Payload | |----------|---------| -| `GET /test` | `{"counter": 0}` — expects a successful response | +| `GET /success` | `{"counter": 0}` — expects a successful response | | `GET /fail` | `{"counter": 0, "fail": "yes"}` — expects an error response | ```bash -make test +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. diff --git a/cmd/ls-api/handler.py b/cmd/ls-api/handler.py index 6e024755..962f78bd 100644 --- a/cmd/ls-api/handler.py +++ b/cmd/ls-api/handler.py @@ -3,4 +3,6 @@ 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})} diff --git a/cmd/ls-api/main.go b/cmd/ls-api/main.go index 5890a132..f8172ca6 100644 --- a/cmd/ls-api/main.go +++ b/cmd/ls-api/main.go @@ -29,7 +29,7 @@ func main() { router.Post("/invocations/{invoke_id}/logs", invokeLogsHandler) router.Post("/status/{runtime_id}/{status}", statusHandler) - router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + router.Get("/success", func(w http.ResponseWriter, r *http.Request) { invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { @@ -107,22 +107,20 @@ func statusHandler(w http.ResponseWriter, r *http.Request) { func invokeResponseHandler(w http.ResponseWriter, r *http.Request) { invokeId := chi.URLParam(r, "invoke_id") - log.Println(invokeId) bodyBytes, err := io.ReadAll(r.Body) if err != nil { log.Error(err) } - log.Println("result: " + string(bodyBytes)) + log.WithFields(log.Fields{"invoke_id": invokeId, "body": string(bodyBytes)}).Info("invokeResponseHandler: received response") w.WriteHeader(http.StatusAccepted) } func invokeErrorHandler(w http.ResponseWriter, r *http.Request) { invokeId := chi.URLParam(r, "invoke_id") - log.Println(invokeId) bodyBytes, err := io.ReadAll(r.Body) if err != nil { log.Error(err) } - log.Println("error result: " + string(bodyBytes)) + log.WithFields(log.Fields{"invoke_id": invokeId, "body": string(bodyBytes)}).Info("invokeErrorHandler: received error") w.WriteHeader(http.StatusAccepted) } diff --git a/cmd/ls-api/main_test.go b/cmd/ls-api/main_test.go index 30bbac76..606332f8 100644 --- a/cmd/ls-api/main_test.go +++ b/cmd/ls-api/main_test.go @@ -20,7 +20,7 @@ import ( const testInvokeID = "test-invoke-id-12345" // newTestRouter creates a chi router with the same LocalStack API routes as main(), -// without the debug /test and /fail endpoints. +// without the debug /success and /fail endpoints. func newTestRouter() *chi.Mux { r := chi.NewRouter() r.Post("/invocations/{invoke_id}/response", invokeResponseHandler) diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-api/smoke-test.sh new file mode 100755 index 00000000..9df79f4f --- /dev/null +++ b/cmd/ls-api/smoke-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# e2e smoke test: starts the ls-api mock and the RIE in Docker, then verifies that +# both a successful and a failing Lambda invocation complete correctly. +# Exits 0 on success, non-zero on failure. Cleans up on exit. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +MOCK_PORT=48490 +INTEROP_PORT=9563 +RIE_BINARY="$REPO_ROOT/bin/aws-lambda-rie-x86_64" +LS_API_BIN="$REPO_ROOT/bin/ls-api" + +LOG_FILE=$(mktemp -t ls-api-smoke.XXXXXX) +CID_FILE=$(mktemp -t rie-smoke.XXXXXX) + +cleanup() { + local cid + cid=$(cat "$CID_FILE" 2>/dev/null || true) + [ -n "$cid" ] && docker stop "$cid" 2>/dev/null || true + [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true + rm -f "$LOG_FILE" "$CID_FILE" +} +trap cleanup EXIT + +wait_for_log() { + local pattern="$1" timeout_sec="$2" elapsed=0 + while (( elapsed < timeout_sec )); do + grep -q "$pattern" "$LOG_FILE" && return 0 + sleep 1 + elapsed=$(( elapsed + 1 )) + done + echo "ERROR: timed out after ${timeout_sec}s waiting for '${pattern}'" >&2 + cat "$LOG_FILE" >&2 + return 1 +} + +# ---- start mock ---- +echo ">>> Starting ls-api mock (port $MOCK_PORT)" +"$LS_API_BIN" > "$LOG_FILE" 2>&1 & +MOCK_PID=$! +for i in $(seq 1 10); do nc -z localhost $MOCK_PORT 2>/dev/null && break || sleep 1; done + +# ---- start RIE ---- +echo ">>> Starting RIE in Docker" +docker_opts=( + --rm --detach + -p "$INTEROP_PORT:$INTEROP_PORT" + -v "$RIE_BINARY:/var/rapid/init:ro" + -v "$SCRIPT_DIR/handler.py:/var/task/handler.py:ro" + -e "LOCALSTACK_RUNTIME_ENDPOINT=http://172.17.0.1:$MOCK_PORT" + -e "LOCALSTACK_RUNTIME_ID=smoke-test-runtime" + -e "AWS_LAMBDA_FUNCTION_TIMEOUT=30" + -e "AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128" + -e "AWS_REGION=us-east-1" + -e "_HANDLER=handler.handler" + --entrypoint /var/rapid/init +) + +CID=$(docker run "${docker_opts[@]}" public.ecr.aws/lambda/python:3.12) +echo "$CID" > "$CID_FILE" + +# ---- verify success invocation ---- +# The mock auto-fires one invocation as soon as it receives POST /status/{id}/ready from the RIE. +echo ">>> Waiting for success invocation (auto-triggered on ready)..." +wait_for_log "invokeResponseHandler" 30 +echo ">>> Success invocation received" + +# ---- verify error invocation ---- +echo ">>> Triggering error invocation..." +curl -sf "http://localhost:$MOCK_PORT/fail" +wait_for_log "invokeErrorHandler" 15 +echo ">>> Error invocation received" + +echo "" +echo "=== Smoke test passed: success + error invocations verified ===" +echo "" +echo "--- ls-api log ---" +cat "$LOG_FILE" From a90c503d8e1e4f87b825e77014c7b037d2dfc6f2 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 16:23:51 +0200 Subject: [PATCH 09/15] fix(ls-api): use host.docker.internal on macOS, improve smoke test debug output 172.17.0.1 is unreachable from Docker containers on macOS (Docker Desktop runs inside a VM). Switch to host.docker.internal on Darwin and keep 172.17.0.1 for Linux where it is the standard bridge gateway. On timeout, also print RIE container status and logs alongside the ls-api log so failures are easier to diagnose. Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/smoke-test.sh | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-api/smoke-test.sh index 9df79f4f..ace54709 100755 --- a/cmd/ls-api/smoke-test.sh +++ b/cmd/ls-api/smoke-test.sh @@ -32,6 +32,15 @@ wait_for_log() { elapsed=$(( elapsed + 1 )) done echo "ERROR: timed out after ${timeout_sec}s waiting for '${pattern}'" >&2 + local cid + cid=$(cat "$CID_FILE" 2>/dev/null || true) + if [ -n "$cid" ]; then + echo "--- RIE container status ---" >&2 + docker inspect "$cid" --format='status={{.State.Status}} exit_code={{.State.ExitCode}}' 2>/dev/null >&2 || true + echo "--- RIE container logs ---" >&2 + docker logs "$cid" 2>&1 >&2 || true + fi + echo "--- ls-api log ---" >&2 cat "$LOG_FILE" >&2 return 1 } @@ -44,12 +53,21 @@ for i in $(seq 1 10); do nc -z localhost $MOCK_PORT 2>/dev/null && break || slee # ---- start RIE ---- echo ">>> Starting RIE in Docker" + +# Docker Desktop on macOS resolves host.docker.internal natively. +# On Linux use the Docker bridge gateway IP directly. +if [[ "$(uname -s)" == "Darwin" ]]; then + MOCK_ENDPOINT="http://host.docker.internal:$MOCK_PORT" +else + MOCK_ENDPOINT="http://172.17.0.1:$MOCK_PORT" +fi + docker_opts=( --rm --detach -p "$INTEROP_PORT:$INTEROP_PORT" -v "$RIE_BINARY:/var/rapid/init:ro" -v "$SCRIPT_DIR/handler.py:/var/task/handler.py:ro" - -e "LOCALSTACK_RUNTIME_ENDPOINT=http://172.17.0.1:$MOCK_PORT" + -e "LOCALSTACK_RUNTIME_ENDPOINT=$MOCK_ENDPOINT" -e "LOCALSTACK_RUNTIME_ID=smoke-test-runtime" -e "AWS_LAMBDA_FUNCTION_TIMEOUT=30" -e "AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128" @@ -60,6 +78,7 @@ docker_opts=( CID=$(docker run "${docker_opts[@]}" public.ecr.aws/lambda/python:3.12) echo "$CID" > "$CID_FILE" +echo ">>> RIE container: $CID (endpoint: $MOCK_ENDPOINT)" # ---- verify success invocation ---- # The mock auto-fires one invocation as soon as it receives POST /status/{id}/ready from the RIE. From 785d93e06248ed45b2a32de80520703e432baa1e Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 16:27:25 +0200 Subject: [PATCH 10/15] fix(ls-api): use --add-host instead of platform-specific host resolution Replace the uname conditional (host.docker.internal vs 172.17.0.1) with --add-host=host.docker.internal:host-gateway, which Docker resolves to the correct gateway IP on both Linux and macOS. Single address, no branching. Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/smoke-test.sh | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-api/smoke-test.sh index ace54709..4d06d01f 100755 --- a/cmd/ls-api/smoke-test.sh +++ b/cmd/ls-api/smoke-test.sh @@ -54,20 +54,13 @@ for i in $(seq 1 10); do nc -z localhost $MOCK_PORT 2>/dev/null && break || slee # ---- start RIE ---- echo ">>> Starting RIE in Docker" -# Docker Desktop on macOS resolves host.docker.internal natively. -# On Linux use the Docker bridge gateway IP directly. -if [[ "$(uname -s)" == "Darwin" ]]; then - MOCK_ENDPOINT="http://host.docker.internal:$MOCK_PORT" -else - MOCK_ENDPOINT="http://172.17.0.1:$MOCK_PORT" -fi - docker_opts=( --rm --detach + --add-host=host.docker.internal:host-gateway -p "$INTEROP_PORT:$INTEROP_PORT" -v "$RIE_BINARY:/var/rapid/init:ro" -v "$SCRIPT_DIR/handler.py:/var/task/handler.py:ro" - -e "LOCALSTACK_RUNTIME_ENDPOINT=$MOCK_ENDPOINT" + -e "LOCALSTACK_RUNTIME_ENDPOINT=http://host.docker.internal:$MOCK_PORT" -e "LOCALSTACK_RUNTIME_ID=smoke-test-runtime" -e "AWS_LAMBDA_FUNCTION_TIMEOUT=30" -e "AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128" @@ -78,7 +71,7 @@ docker_opts=( CID=$(docker run "${docker_opts[@]}" public.ecr.aws/lambda/python:3.12) echo "$CID" > "$CID_FILE" -echo ">>> RIE container: $CID (endpoint: $MOCK_ENDPOINT)" +echo ">>> RIE container: $CID" # ---- verify success invocation ---- # The mock auto-fires one invocation as soon as it receives POST /status/{id}/ready from the RIE. From c34792fc984761588b9e14b855c72c1aed345a3d Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 16:31:39 +0200 Subject: [PATCH 11/15] fix(ls-api): fix smoke test on Apple Silicon, improve container debug output Two fixes: 1. Add --platform linux/amd64 to docker run so Docker always pulls the x86_64 image. On Apple Silicon, Docker otherwise selects the arm64 image, causing an exec format error when mounting the linux/amd64 RIE binary -- the container exits immediately without sending any callbacks. 2. Drop --rm and do explicit docker rm in cleanup instead. With --rm the container and its logs are deleted on exit before wait_for_log can retrieve them, making failures invisible. Without --rm, docker logs and docker inspect work correctly even after the container has stopped. Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/smoke-test.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-api/smoke-test.sh index 4d06d01f..13159653 100755 --- a/cmd/ls-api/smoke-test.sh +++ b/cmd/ls-api/smoke-test.sh @@ -18,7 +18,10 @@ CID_FILE=$(mktemp -t rie-smoke.XXXXXX) cleanup() { local cid cid=$(cat "$CID_FILE" 2>/dev/null || true) - [ -n "$cid" ] && docker stop "$cid" 2>/dev/null || true + if [ -n "$cid" ]; then + docker stop "$cid" 2>/dev/null || true + docker rm -f "$cid" 2>/dev/null || true + fi [ -n "${MOCK_PID:-}" ] && kill "$MOCK_PID" 2>/dev/null || true rm -f "$LOG_FILE" "$CID_FILE" } @@ -55,7 +58,8 @@ for i in $(seq 1 10); do nc -z localhost $MOCK_PORT 2>/dev/null && break || slee echo ">>> Starting RIE in Docker" docker_opts=( - --rm --detach + --detach + --platform linux/amd64 --add-host=host.docker.internal:host-gateway -p "$INTEROP_PORT:$INTEROP_PORT" -v "$RIE_BINARY:/var/rapid/init:ro" From 73001c5a1d463f3aa05f2ebea0e93608d3744063 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Wed, 3 Jun 2026 16:43:14 +0200 Subject: [PATCH 12/15] fix(ls-api): fix AWS_LAMBDA_FUNCTION_VERSION panic, deduplicate docker flags Two changes: 1. Add AWS_LAMBDA_FUNCTION_VERSION (and --platform/--add-host) to a shared RIE_DOCKER_OPTS variable in the Makefile. smoke-test.sh was missing this env var, causing a startup panic in the RIE. RIE_DOCKER_OPTS uses deferred assignment (=) so $$LATEST survives to recipe expansion time. 2. Replace the duplicated docker_opts array in smoke-test.sh with a call to the new start-rie-detached Makefile target. Both start-rie and start-rie-detached now use the same RIE_DOCKER_OPTS, so env vars stay in sync automatically. Co-Authored-By: Claude Sonnet 4.6 --- cmd/ls-api/Makefile | 37 +++++++++++++++++++++++-------------- cmd/ls-api/smoke-test.sh | 22 ++-------------------- 2 files changed, 25 insertions(+), 34 deletions(-) diff --git a/cmd/ls-api/Makefile b/cmd/ls-api/Makefile index 3ed21476..47cc74a9 100644 --- a/cmd/ls-api/Makefile +++ b/cmd/ls-api/Makefile @@ -7,7 +7,25 @@ INTEROP_PORT := 9563 RIE_BINARY := $(REPO_ROOT)/bin/aws-lambda-rie-$(ARCH) LS_API_BIN := $(REPO_ROOT)/bin/ls-api -.PHONY: build-rie build-ls-api start-mock start-rie success fail smoke-test +# 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 @@ -19,19 +37,10 @@ start-mock: ## Run the ls-api LocalStack endpoint mock natively (no Docker need go run $(THIS_MAKEFILE_DIR) start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda container - docker run --rm \ - -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 \ - public.ecr.aws/lambda/python:3.12 + 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 diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-api/smoke-test.sh index 13159653..117f3d76 100755 --- a/cmd/ls-api/smoke-test.sh +++ b/cmd/ls-api/smoke-test.sh @@ -8,8 +8,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" MOCK_PORT=48490 -INTEROP_PORT=9563 -RIE_BINARY="$REPO_ROOT/bin/aws-lambda-rie-x86_64" LS_API_BIN="$REPO_ROOT/bin/ls-api" LOG_FILE=$(mktemp -t ls-api-smoke.XXXXXX) @@ -55,25 +53,9 @@ MOCK_PID=$! for i in $(seq 1 10); do nc -z localhost $MOCK_PORT 2>/dev/null && break || sleep 1; done # ---- start RIE ---- +# Docker flags are defined once in the Makefile (RIE_DOCKER_OPTS) and shared with start-rie. echo ">>> Starting RIE in Docker" - -docker_opts=( - --detach - --platform linux/amd64 - --add-host=host.docker.internal:host-gateway - -p "$INTEROP_PORT:$INTEROP_PORT" - -v "$RIE_BINARY:/var/rapid/init:ro" - -v "$SCRIPT_DIR/handler.py:/var/task/handler.py:ro" - -e "LOCALSTACK_RUNTIME_ENDPOINT=http://host.docker.internal:$MOCK_PORT" - -e "LOCALSTACK_RUNTIME_ID=smoke-test-runtime" - -e "AWS_LAMBDA_FUNCTION_TIMEOUT=30" - -e "AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128" - -e "AWS_REGION=us-east-1" - -e "_HANDLER=handler.handler" - --entrypoint /var/rapid/init -) - -CID=$(docker run "${docker_opts[@]}" public.ecr.aws/lambda/python:3.12) +CID=$(make -s --no-print-directory -C "$SCRIPT_DIR" start-rie-detached) echo "$CID" > "$CID_FILE" echo ">>> RIE container: $CID" From 04f14090a2a0f7f0f51b37a5833c103c1bf06872 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Mon, 8 Jun 2026 17:17:21 +0200 Subject: [PATCH 13/15] =?UTF-8?q?refactor(ls-api):=20extract=20shared=20LS?= =?UTF-8?q?=E2=86=94RIE=20API=20types=20into=20internal/lsapi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InvokeRequest, LogResponse, and ErrorResponse were duplicated across cmd/localstack and cmd/ls-api. Moving them to internal/lsapi/types.go makes both packages use the same definitions and removes the duplication flagged in the PR review. Co-Authored-By: Claude Sonnet 4.6 --- cmd/localstack/custom_interop.go | 23 ++++------------------- cmd/localstack/custom_interop_test.go | 9 +++++---- cmd/localstack/logs.go | 10 ++++------ cmd/ls-api/main.go | 27 ++++++++------------------- cmd/ls-api/main_test.go | 7 ++++--- internal/lsapi/types.go | 22 ++++++++++++++++++++++ 6 files changed, 47 insertions(+), 51 deletions(-) create mode 100644 internal/lsapi/types.go diff --git a/cmd/localstack/custom_interop.go b/cmd/localstack/custom_interop.go index 6b89d656..f5475511 100644 --- a/cmd/localstack/custom_interop.go +++ b/cmd/localstack/custom_interop.go @@ -18,6 +18,7 @@ import ( "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop" "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore" "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore/standalone" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi" "github.com/go-chi/chi/v5" log "github.com/sirupsen/logrus" ) @@ -51,7 +52,7 @@ func (l *LocalStackAdapter) SendStatus(status LocalStackStatus, payload []byte) } // SendLogs posts the captured invocation logs to LocalStack. -func (l *LocalStackAdapter) SendLogs(invokeId string, logs LogResponse) error { +func (l *LocalStackAdapter) SendLogs(invokeId string, logs lsapi.LogResponse) error { serialized, err := json.Marshal(logs) if err != nil { return err @@ -81,22 +82,6 @@ func (l *LocalStackAdapter) SendResult(invokeId string, body []byte, isError boo return err } -// The InvokeRequest is sent by LocalStack to trigger an invocation -type InvokeRequest struct { - InvokeId string `json:"invoke-id"` - InvokedFunctionArn string `json:"invoked-function-arn"` - Payload string `json:"payload"` - TraceId string `json:"trace-id"` -} - -// The ErrorResponse is sent TO LocalStack when encountering an error -type ErrorResponse struct { - ErrorMessage string `json:"errorMessage"` - ErrorType string `json:"errorType,omitempty"` - RequestId string `json:"requestId,omitempty"` - StackTrace []string `json:"stackTrace,omitempty"` -} - func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollector *LogCollector) (server *CustomInteropServer) { server = &CustomInteropServer{ delegate: delegate.(*rapidcore.Server), @@ -112,7 +97,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto go func() { r := chi.NewRouter() r.Post("/invoke", func(w http.ResponseWriter, r *http.Request) { - invokeR := InvokeRequest{} + invokeR := lsapi.InvokeRequest{} bytess, err := io.ReadAll(r.Body) if err != nil { log.Error(err) @@ -154,7 +139,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto case errors.Is(err, rapidcore.ErrInvokeTimeout): log.Debugf("Got invoke timeout") isErr = true - errorResponse := ErrorResponse{ + errorResponse := lsapi.ErrorResponse{ ErrorMessage: fmt.Sprintf( "%s %s Task timed out after %d.00 seconds", time.Now().Format("2006-01-02T15:04:05Z"), diff --git a/cmd/localstack/custom_interop_test.go b/cmd/localstack/custom_interop_test.go index 2e54313e..d089106d 100644 --- a/cmd/localstack/custom_interop_test.go +++ b/cmd/localstack/custom_interop_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "testing" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -28,7 +29,7 @@ func TestInvokeRequestContract(t *testing.T) { "trace-id": "Root=1-abc;Parent=def;Sampled=1" }` - var req InvokeRequest + var req lsapi.InvokeRequest require.NoError(t, json.Unmarshal([]byte(raw), &req)) assert.Equal(t, "abc-123", req.InvokeId) @@ -42,7 +43,7 @@ func TestInvokeRequestContract(t *testing.T) { func TestLogResponseContract(t *testing.T) { raw := `{"logs":"START RequestId: abc\nEND RequestId: abc\n"}` - var lr LogResponse + var lr lsapi.LogResponse require.NoError(t, json.Unmarshal([]byte(raw), &lr)) assert.Equal(t, "START RequestId: abc\nEND RequestId: abc\n", lr.Logs) @@ -84,7 +85,7 @@ func TestSendStatus_ErrorSendsToCorrectPath(t *testing.T) { func TestSendLogs_SendsJSONWithLogsKey(t *testing.T) { var capturedPath string - var capturedBody LogResponse + var capturedBody lsapi.LogResponse srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { capturedPath = r.URL.Path body, _ := io.ReadAll(r.Body) @@ -94,7 +95,7 @@ func TestSendLogs_SendsJSONWithLogsKey(t *testing.T) { defer srv.Close() adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL} - logs := LogResponse{Logs: "START RequestId: invoke-1\nEND RequestId: invoke-1\n"} + logs := lsapi.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) diff --git a/cmd/localstack/logs.go b/cmd/localstack/logs.go index 0a9a5a79..ac11b0a7 100644 --- a/cmd/localstack/logs.go +++ b/cmd/localstack/logs.go @@ -3,11 +3,9 @@ package main import ( "strings" "sync" -) -type LogResponse struct { - Logs string `json:"logs"` -} + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi" +) type LogCollector struct { mutex *sync.Mutex @@ -37,10 +35,10 @@ func (lc *LogCollector) reset() { lc.RuntimeLogs = []string{} } -func (lc *LogCollector) getLogs() LogResponse { +func (lc *LogCollector) getLogs() lsapi.LogResponse { lc.mutex.Lock() defer lc.mutex.Unlock() - response := LogResponse{ + response := lsapi.LogResponse{ Logs: strings.Join(lc.RuntimeLogs, ""), } lc.RuntimeLogs = []string{} diff --git a/cmd/ls-api/main.go b/cmd/ls-api/main.go index f8172ca6..c2616c25 100644 --- a/cmd/ls-api/main.go +++ b/cmd/ls-api/main.go @@ -5,11 +5,13 @@ import ( "bytes" "encoding/json" "fmt" + "io" + "net/http" + + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" log "github.com/sirupsen/logrus" - "io" - "net/http" ) const apiPort = 9563 @@ -30,7 +32,7 @@ func main() { router.Post("/status/{runtime_id}/{status}", statusHandler) router.Get("/success", func(w http.ResponseWriter, r *http.Request) { - invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0}"}) + invokeRequest, _ := json.Marshal(lsapi.InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { log.Error(err) @@ -44,7 +46,7 @@ func main() { }) router.Get("/fail", func(w http.ResponseWriter, r *http.Request) { - invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0, \"fail\": \"yes\"}"}) + invokeRequest, _ := json.Marshal(lsapi.InvokeRequest{InvokeId: uid, Payload: "{\"counter\":0, \"fail\": \"yes\"}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { log.Error(err) @@ -67,7 +69,7 @@ func main() { func invokeLogsHandler(w http.ResponseWriter, r *http.Request) { invokeId := chi.URLParam(r, "invoke_id") log.Println(invokeId) - var logResponse LogResponse + var logResponse lsapi.LogResponse if err := json.NewDecoder(r.Body).Decode(&logResponse); err != nil { log.Error("invalid logs payload: ", err) } else { @@ -76,26 +78,13 @@ func invokeLogsHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) } -// InvokeRequest is sent by LocalStack to trigger an invocation. -type InvokeRequest struct { - InvokeId string `json:"invoke-id"` - InvokedFunctionArn string `json:"invoked-function-arn"` - Payload string `json:"payload"` - TraceId string `json:"trace-id"` -} - -// LogResponse is sent by the runtime to report logs for a completed invocation. -type LogResponse struct { - Logs string `json:"logs"` -} - func statusHandler(w http.ResponseWriter, r *http.Request) { runtime_id := chi.URLParam(r, "runtime_id") status := chi.URLParam(r, "status") log.Println(runtime_id + " + " + status) if status == "ready" { go func() { - invokeRequest, _ := json.Marshal(InvokeRequest{InvokeId: "12345", Payload: "{\"counter\":0}"}) + invokeRequest, _ := json.Marshal(lsapi.InvokeRequest{InvokeId: "12345", Payload: "{\"counter\":0}"}) _, err := http.Post(invokeUrl, "application/json", bytes.NewReader(invokeRequest)) if err != nil { log.Error(err) diff --git a/cmd/ls-api/main_test.go b/cmd/ls-api/main_test.go index 606332f8..c1b683c8 100644 --- a/cmd/ls-api/main_test.go +++ b/cmd/ls-api/main_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi" "github.com/go-chi/chi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -71,7 +72,7 @@ func TestInvocationLogsReturns202(t *testing.T) { srv := httptest.NewServer(newTestRouter()) defer srv.Close() - logPayload, err := json.Marshal(LogResponse{ + logPayload, err := json.Marshal(lsapi.LogResponse{ Logs: "START RequestId: " + testInvokeID + " Version: $LATEST\nEND RequestId: " + testInvokeID + "\n", }) require.NoError(t, err) @@ -91,9 +92,9 @@ func TestInvocationLogsReturns202(t *testing.T) { // - returns 202 Accepted (matching LocalStack executor_endpoint.py status_ready) // - asynchronously sends a POST to the invoke endpoint with a valid InvokeRequest body func TestStatusReadyReturns202AndTriggersInvoke(t *testing.T) { - invokeCh := make(chan InvokeRequest, 1) + invokeCh := make(chan lsapi.InvokeRequest, 1) captureServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var req InvokeRequest + var req lsapi.InvokeRequest require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) invokeCh <- req w.WriteHeader(http.StatusOK) diff --git a/internal/lsapi/types.go b/internal/lsapi/types.go new file mode 100644 index 00000000..d38776d2 --- /dev/null +++ b/internal/lsapi/types.go @@ -0,0 +1,22 @@ +package lsapi + +// InvokeRequest is sent by LocalStack to trigger an invocation. +type InvokeRequest struct { + InvokeId string `json:"invoke-id"` + InvokedFunctionArn string `json:"invoked-function-arn"` + Payload string `json:"payload"` + TraceId string `json:"trace-id"` +} + +// LogResponse is sent by the runtime to report logs for a completed invocation. +type LogResponse struct { + Logs string `json:"logs"` +} + +// ErrorResponse is sent to LocalStack when encountering an error. +type ErrorResponse struct { + ErrorMessage string `json:"errorMessage"` + ErrorType string `json:"errorType,omitempty"` + RequestId string `json:"requestId,omitempty"` + StackTrace []string `json:"stackTrace,omitempty"` +} From 370b682fd66eb9921521c595ae4ed488ed18e417 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Mon, 8 Jun 2026 17:21:42 +0200 Subject: [PATCH 14/15] chore: ignore root-level go build artifacts go build without -o drops binaries in the working directory; the Makefile targets use -o bin/... (already ignored) but a direct go build shortcut would leave stray files. Add aws-lambda-rie and ls-api to .gitignore. Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 85b35a21..46f2c1d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ /pkg /build /bin +aws-lambda-rie +ls-api *.swp *.iml tags From 0fbe568d526a2607af342f35865266ba1c824b09 Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Mon, 8 Jun 2026 17:38:32 +0200 Subject: [PATCH 15/15] refactor: rename ls-api to ls-mock Distinguishes the LocalStack endpoint mock (cmd/ls-mock, bin/ls-mock) from the internal/lsapi package introduced in the previous commit. Updates all references: Makefile, smoke-test.sh, CI workflow, README, .gitignore, and test comments. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/ls-smoke-tests.yml | 4 ++-- .gitignore | 2 +- README-LOCALSTACK.md | 6 +++--- cmd/{ls-api => ls-mock}/Makefile | 12 ++++++------ cmd/{ls-api => ls-mock}/README.md | 4 ++-- cmd/{ls-api => ls-mock}/handler.py | 0 cmd/{ls-api => ls-mock}/main.go | 0 cmd/{ls-api => ls-mock}/main_test.go | 2 +- cmd/{ls-api => ls-mock}/smoke-test.sh | 12 ++++++------ 9 files changed, 21 insertions(+), 21 deletions(-) rename cmd/{ls-api => ls-mock}/Makefile (80%) rename cmd/{ls-api => ls-mock}/README.md (91%) rename cmd/{ls-api => ls-mock}/handler.py (100%) rename cmd/{ls-api => ls-mock}/main.go (100%) rename cmd/{ls-api => ls-mock}/main_test.go (98%) rename cmd/{ls-api => ls-mock}/smoke-test.sh (89%) diff --git a/.github/workflows/ls-smoke-tests.yml b/.github/workflows/ls-smoke-tests.yml index 50ea67bc..0a42e5c5 100644 --- a/.github/workflows/ls-smoke-tests.yml +++ b/.github/workflows/ls-smoke-tests.yml @@ -11,7 +11,7 @@ concurrency: cancel-in-progress: true jobs: - smoke-ls-api: + smoke-ls-mock: name: RIE ↔ LocalStack API Smoke Test runs-on: ubuntu-latest steps: @@ -23,4 +23,4 @@ jobs: go-version-file: go.mod - name: Run smoke test - run: make -C cmd/ls-api smoke-test + run: make -C cmd/ls-mock smoke-test diff --git a/.gitignore b/.gitignore index 46f2c1d7..6c886eba 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ /build /bin aws-lambda-rie -ls-api +ls-mock *.swp *.iml tags diff --git a/README-LOCALSTACK.md b/README-LOCALSTACK.md index 1e8a9d72..4e3196df 100644 --- a/README-LOCALSTACK.md +++ b/README-LOCALSTACK.md @@ -18,8 +18,8 @@ Refer to [debugging/README.md](./debugging/README.md) for instructions on how to | `cmd/localstack` | LocalStack customizations | | ├── `main.go` | Main entrypoint | | ├── `custom_interop.go` | Custom server interface between the Lambda runtime API and this Go init. Implements the `Server` interface from `lambda/interop/model.go:Server` but forwards most calls to the original implementation in `lambda/rapidcore/server.go` available as `delegate`. | -| `cmd/ls-api` | Mock LocalStack component for smoke testing | -| ├── [`README.md`](./cmd/ls-api/README.md) | Instructions for LS API<->RIE smoke testing | +| `cmd/ls-mock` | Mock LocalStack component for smoke testing | +| ├── [`README.md`](./cmd/ls-mock/README.md) | Instructions for LS API<->RIE smoke testing | | `debugging/` | Debug and test this Go init with LocalStack | | ├── [`README.md`](./debugging/README.md) | Instructions for building and debugging with LocalStack | | `lambda` | Original AWS implementation of the runtime emulator ideally kept untouched | @@ -43,6 +43,6 @@ Example PR that integrates upstream changes: https://github.com/localstack/lambd Document all custom changes with the following comment prefix `# LOCALSTACK CHANGES yyyy-mm-dd:` -* Everything in `cmd/localstack`, `cmd/ls-api`, and `.github` +* Everything in `cmd/localstack`, `cmd/ls-mock`, and `.github` * `Makefile` for debugging and building with Docker * 2023-10-17: `lambda/rapidcore/server.go` pass request metadata into .Reserve(invoke.ID, invoke.TraceID, invoke.LambdaSegmentID) diff --git a/cmd/ls-api/Makefile b/cmd/ls-mock/Makefile similarity index 80% rename from cmd/ls-api/Makefile rename to cmd/ls-mock/Makefile index 47cc74a9..cbd742f1 100644 --- a/cmd/ls-api/Makefile +++ b/cmd/ls-mock/Makefile @@ -5,7 +5,7 @@ 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 +LS_MOCK_BIN := $(REPO_ROOT)/bin/ls-mock # Common docker flags for start-rie and start-rie-detached. # Uses deferred assignment (=) so $$LATEST is not expanded until recipe time, @@ -25,15 +25,15 @@ RIE_DOCKER_OPTS = \ -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 +.PHONY: build-rie build-ls-mock 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) +build-ls-mock: ## Build the ls-mock binary + go build -o $(LS_MOCK_BIN) $(THIS_MAKEFILE_DIR) -start-mock: ## Run the ls-api LocalStack endpoint mock natively (no Docker needed) +start-mock: ## Run the ls-mock 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 @@ -48,5 +48,5 @@ success: ## Trigger a successful invocation via the mock's /success endpoint 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 +smoke-test: build-rie build-ls-mock ## Full e2e smoke test: start mock + RIE, verify success + error invocations, cleanup $(THIS_MAKEFILE_DIR)/smoke-test.sh diff --git a/cmd/ls-api/README.md b/cmd/ls-mock/README.md similarity index 91% rename from cmd/ls-api/README.md rename to cmd/ls-mock/README.md index ff7a2fa4..05cf66f2 100644 --- a/cmd/ls-api/README.md +++ b/cmd/ls-mock/README.md @@ -1,4 +1,4 @@ -# ls-api — LocalStack endpoint mock +# ls-mock — 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. @@ -60,4 +60,4 @@ To run the full e2e smoke test non-interactively (used in CI): 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 builds both the RIE binary and the ls-mock mock, starts them, verifies a successful and a failing invocation, then cleans up. diff --git a/cmd/ls-api/handler.py b/cmd/ls-mock/handler.py similarity index 100% rename from cmd/ls-api/handler.py rename to cmd/ls-mock/handler.py diff --git a/cmd/ls-api/main.go b/cmd/ls-mock/main.go similarity index 100% rename from cmd/ls-api/main.go rename to cmd/ls-mock/main.go diff --git a/cmd/ls-api/main_test.go b/cmd/ls-mock/main_test.go similarity index 98% rename from cmd/ls-api/main_test.go rename to cmd/ls-mock/main_test.go index c1b683c8..b4100883 100644 --- a/cmd/ls-api/main_test.go +++ b/cmd/ls-mock/main_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/require" ) -// These tests verify the ls-api mock server (cmd/ls-api) — a manual testing tool that +// These tests verify the ls-mock mock server (cmd/ls-mock) — a manual testing tool that // emulates the LocalStack endpoint locally. They do NOT test the production RIE code. // For regression tests of the actual LS↔RIE API contract, see cmd/localstack/custom_interop_test.go. diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-mock/smoke-test.sh similarity index 89% rename from cmd/ls-api/smoke-test.sh rename to cmd/ls-mock/smoke-test.sh index 117f3d76..1d6e32d4 100755 --- a/cmd/ls-api/smoke-test.sh +++ b/cmd/ls-mock/smoke-test.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# e2e smoke test: starts the ls-api mock and the RIE in Docker, then verifies that +# e2e smoke test: starts the ls-mock mock and the RIE in Docker, then verifies that # both a successful and a failing Lambda invocation complete correctly. # Exits 0 on success, non-zero on failure. Cleans up on exit. set -euo pipefail @@ -8,9 +8,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" MOCK_PORT=48490 -LS_API_BIN="$REPO_ROOT/bin/ls-api" +LS_API_BIN="$REPO_ROOT/bin/ls-mock" -LOG_FILE=$(mktemp -t ls-api-smoke.XXXXXX) +LOG_FILE=$(mktemp -t ls-mock-smoke.XXXXXX) CID_FILE=$(mktemp -t rie-smoke.XXXXXX) cleanup() { @@ -41,13 +41,13 @@ wait_for_log() { echo "--- RIE container logs ---" >&2 docker logs "$cid" 2>&1 >&2 || true fi - echo "--- ls-api log ---" >&2 + echo "--- ls-mock log ---" >&2 cat "$LOG_FILE" >&2 return 1 } # ---- start mock ---- -echo ">>> Starting ls-api mock (port $MOCK_PORT)" +echo ">>> Starting ls-mock mock (port $MOCK_PORT)" "$LS_API_BIN" > "$LOG_FILE" 2>&1 & MOCK_PID=$! for i in $(seq 1 10); do nc -z localhost $MOCK_PORT 2>/dev/null && break || sleep 1; done @@ -74,5 +74,5 @@ echo ">>> Error invocation received" echo "" echo "=== Smoke test passed: success + error invocations verified ===" echo "" -echo "--- ls-api log ---" +echo "--- ls-mock log ---" cat "$LOG_FILE"