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/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/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/Makefile b/cmd/ls-api/Makefile new file mode 100644 index 00000000..47cc74a9 --- /dev/null +++ b/cmd/ls-api/Makefile @@ -0,0 +1,52 @@ +THIS_MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))) +REPO_ROOT := $(abspath $(THIS_MAKEFILE_DIR)/../..) + +ARCH ?= x86_64 +MOCK_PORT := 48490 +INTEROP_PORT := 9563 +RIE_BINARY := $(REPO_ROOT)/bin/aws-lambda-rie-$(ARCH) +LS_API_BIN := $(REPO_ROOT)/bin/ls-api + +# Common docker flags for start-rie and start-rie-detached. +# Uses deferred assignment (=) so $$LATEST is not expanded until recipe time, +# where Make reduces $$ -> $ before passing the line to the shell. +RIE_DOCKER_OPTS = \ + --platform linux/amd64 \ + --add-host=host.docker.internal:host-gateway \ + -p $(INTEROP_PORT):$(INTEROP_PORT) \ + -v $(RIE_BINARY):/var/rapid/init:ro \ + -v $(THIS_MAKEFILE_DIR)/handler.py:/var/task/handler.py:ro \ + -e LOCALSTACK_RUNTIME_ENDPOINT=http://host.docker.internal:$(MOCK_PORT) \ + -e LOCALSTACK_RUNTIME_ID=test-runtime-id \ + -e AWS_LAMBDA_FUNCTION_TIMEOUT=30 \ + -e AWS_LAMBDA_FUNCTION_VERSION='$$LATEST' \ + -e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 \ + -e AWS_REGION=us-east-1 \ + -e _HANDLER=handler.handler \ + --entrypoint /var/rapid/init + +.PHONY: build-rie build-ls-api start-mock start-rie start-rie-detached success fail smoke-test + +build-rie: ## Build the RIE Linux binary via Go cross-compilation (works on macOS) + $(MAKE) -C $(REPO_ROOT) ARCH=$(ARCH) compile-lambda-linux + +build-ls-api: ## Build the ls-api mock binary + go build -o $(LS_API_BIN) $(THIS_MAKEFILE_DIR) + +start-mock: ## Run the ls-api LocalStack endpoint mock natively (no Docker needed) + go run $(THIS_MAKEFILE_DIR) + +start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda container + docker run --rm $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12 + +start-rie-detached: ## Start the RIE in detached mode; prints container ID (binaries must be pre-built) + @docker run --detach $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12 + +success: ## Trigger a successful invocation via the mock's /success endpoint + curl -sf http://localhost:$(MOCK_PORT)/success + +fail: ## Trigger an error invocation via the mock's /fail endpoint + curl -sf http://localhost:$(MOCK_PORT)/fail + +smoke-test: build-rie build-ls-api ## Full e2e smoke test: start mock + RIE, verify success + error invocations, cleanup + $(THIS_MAKEFILE_DIR)/smoke-test.sh diff --git a/cmd/ls-api/README.md b/cmd/ls-api/README.md new file mode 100644 index 00000000..ff7a2fa4 --- /dev/null +++ b/cmd/ls-api/README.md @@ -0,0 +1,63 @@ +# ls-api — LocalStack endpoint mock + +A lightweight HTTP server that stands in for the LocalStack endpoint when testing the RIE in isolation, without a running LocalStack instance. + +## Ports + +| Port | Direction | Purpose | +|------|-----------|---------| +| `48490` | inbound | Receives callbacks from the RIE (logs, response, status) | +| `9563` | outbound | Sends invocations to the RIE's `/invoke` endpoint; must be **exposed** when the RIE runs in Docker | + +## Prerequisites + +- Go toolchain — to run the mock +- Docker Desktop — to run the RIE (the binary targets Linux) + +## How to use + +**Terminal 1 — start the mock:** + +```bash +make start-mock +``` + +**Terminal 2 — build and start the RIE** pointing at the mock: + +```bash +make start-rie +``` + +This cross-compiles the RIE for Linux and runs it inside a `public.ecr.aws/lambda/python:3.12` container using `handler.py` as the Lambda function. Port `9563` is exposed so the mock can deliver invocations. Once the RIE sends `POST /status/{id}/ready`, the mock automatically fires one invocation with `{"counter": 0}` and logs the result. + +To build for ARM (e.g. Apple Silicon): + +```bash +make start-rie ARCH=arm64 +``` + +## Trigger endpoints + +Two helper endpoints let you fire additional invocations manually after startup: + +| Endpoint | Payload | +|----------|---------| +| `GET /success` | `{"counter": 0}` — expects a successful response | +| `GET /fail` | `{"counter": 0, "fail": "yes"}` — expects an error response | + +```bash +make success +make fail +``` + +All RIE callbacks (`/invocations/*/response`, `/invocations/*/error`, `/invocations/*/logs`, `/status/*/*`) are logged to stdout and return `202 Accepted`. + +## Automated smoke test + +To run the full e2e smoke test non-interactively (used in CI): + +```bash +make smoke-test +``` + +This builds both the RIE binary and the ls-api mock, starts them, verifies a successful and a failing invocation, then cleans up. diff --git a/cmd/ls-api/handler.py b/cmd/ls-api/handler.py new file mode 100644 index 00000000..962f78bd --- /dev/null +++ b/cmd/ls-api/handler.py @@ -0,0 +1,8 @@ +import json + + +def handler(event, context): + print(f"Received: {json.dumps(event)}") + if event.get("fail"): + raise Exception(f"Intentional failure: fail={event['fail']}") + return {"statusCode": 200, "body": json.dumps({"echo": event})} diff --git a/cmd/ls-api/main.go b/cmd/ls-api/main.go index bd979af7..f8172ca6 100644 --- a/cmd/ls-api/main.go +++ b/cmd/ls-api/main.go @@ -29,11 +29,11 @@ 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 { - 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) @@ -66,16 +67,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,23 +98,29 @@ 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) { 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.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 new file mode 100644 index 00000000..606332f8 --- /dev/null +++ b/cmd/ls-api/main_test.go @@ -0,0 +1,144 @@ +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" +) + +// 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(), +// without the debug /success 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) +} diff --git a/cmd/ls-api/smoke-test.sh b/cmd/ls-api/smoke-test.sh new file mode 100755 index 00000000..117f3d76 --- /dev/null +++ b/cmd/ls-api/smoke-test.sh @@ -0,0 +1,78 @@ +#!/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 +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) + 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" +} +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 + 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 +} + +# ---- 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 ---- +# Docker flags are defined once in the Makefile (RIE_DOCKER_OPTS) and shared with start-rie. +echo ">>> Starting RIE in Docker" +CID=$(make -s --no-print-directory -C "$SCRIPT_DIR" start-rie-detached) +echo "$CID" > "$CID_FILE" +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. +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"