From 9fa2b7f605bd4792d6fcd07f5c6f4386238eea4f Mon Sep 17 00:00:00 2001 From: Joel Scheuner Date: Tue, 2 Jun 2026 17:31:03 +0200 Subject: [PATCH 01/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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/12] 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"