Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ls-smoke-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: LocalStack Smoke Tests

on:
push:
branches: [localstack]
pull_request:
branches: [localstack]

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

jobs:
smoke-ls-api:
name: RIE ↔ LocalStack API Smoke Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod

- name: Run smoke test
run: make -C cmd/ls-api smoke-test
3 changes: 2 additions & 1 deletion README-LOCALSTACK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
59 changes: 35 additions & 24 deletions cmd/localstack/custom_interop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are the main refactoring changes to LS RIE production code. Extracting SendLogs and SendResult is needed for testing.

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"`
Expand Down Expand Up @@ -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)
}
}()

Expand Down
149 changes: 149 additions & 0 deletions cmd/localstack/custom_interop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package main

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// --- JSON contract tests ---

// TestInvokeRequestContract verifies that InvokeRequest correctly maps the JSON field names
// that LocalStack sends to the RIE's /invoke endpoint (defined in
// localstack-pro/localstack-core/localstack/services/lambda_/invocation/execution_environment.py).
//
// WARNING: The LocalStack↔RIE API contract is currently unversioned. Any change to these
// field names is a silent breaking change that requires a coordinated update of both
// localstack-pro and lambda-runtime-init with no safe rollback path.
func TestInvokeRequestContract(t *testing.T) {
raw := `{
"invoke-id": "abc-123",
"invoked-function-arn": "arn:aws:lambda:us-east-1:000000000000:function:my-fn",
"payload": "{\"key\":\"value\"}",
"trace-id": "Root=1-abc;Parent=def;Sampled=1"
}`

var req InvokeRequest
require.NoError(t, json.Unmarshal([]byte(raw), &req))

assert.Equal(t, "abc-123", req.InvokeId)
assert.Equal(t, "arn:aws:lambda:us-east-1:000000000000:function:my-fn", req.InvokedFunctionArn)
assert.Equal(t, `{"key":"value"}`, req.Payload)
assert.Equal(t, "Root=1-abc;Parent=def;Sampled=1", req.TraceId)
}

// TestLogResponseContract verifies that LogResponse uses the "logs" JSON key expected by
// LocalStack's invocation_logs handler (executor_endpoint.py).
func TestLogResponseContract(t *testing.T) {
raw := `{"logs":"START RequestId: abc\nEND RequestId: abc\n"}`

var lr LogResponse
require.NoError(t, json.Unmarshal([]byte(raw), &lr))

assert.Equal(t, "START RequestId: abc\nEND RequestId: abc\n", lr.Logs)
}

// --- LocalStackAdapter.SendStatus tests ---

func TestSendStatus_ReadySendsToCorrectPath(t *testing.T) {
var capturedReq *http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedReq = r
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()

adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL, RuntimeId: "runtime-abc"}
require.NoError(t, adapter.SendStatus(Ready, []byte{}))

assert.Equal(t, http.MethodPost, capturedReq.Method)
assert.Equal(t, "/status/runtime-abc/ready", capturedReq.URL.Path)
}

func TestSendStatus_ErrorSendsToCorrectPath(t *testing.T) {
var capturedReq *http.Request
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedReq = r
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()

adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL, RuntimeId: "runtime-abc"}
require.NoError(t, adapter.SendStatus(Error, []byte(`{"errorMessage":"init failed"}`)))

assert.Equal(t, http.MethodPost, capturedReq.Method)
assert.Equal(t, "/status/runtime-abc/error", capturedReq.URL.Path)
}

// --- LocalStackAdapter.SendLogs tests ---

func TestSendLogs_SendsJSONWithLogsKey(t *testing.T) {
var capturedPath string
var capturedBody LogResponse
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &capturedBody)
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()

adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
logs := LogResponse{Logs: "START RequestId: invoke-1\nEND RequestId: invoke-1\n"}
require.NoError(t, adapter.SendLogs("invoke-1", logs))

assert.Equal(t, "/invocations/invoke-1/logs", capturedPath)
assert.Equal(t, logs.Logs, capturedBody.Logs)
}

// --- LocalStackAdapter.SendResult routing tests ---

func TestSendResult_SuccessGoesToResponseEndpoint(t *testing.T) {
var capturedPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()

adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
require.NoError(t, adapter.SendResult("invoke-1", []byte(`{"result":"ok"}`), false))

assert.Equal(t, "/invocations/invoke-1/response", capturedPath)
}

func TestSendResult_ErrorBodyGoesToErrorEndpoint(t *testing.T) {
var capturedPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()

// Body contains "errorType" — LocalStack distinguishes function errors this way
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
errBody := []byte(`{"errorMessage":"something went wrong","errorType":"RuntimeError"}`)
require.NoError(t, adapter.SendResult("invoke-1", errBody, false))

assert.Equal(t, "/invocations/invoke-1/error", capturedPath)
}

func TestSendResult_ExplicitErrorFlagGoesToErrorEndpoint(t *testing.T) {
var capturedPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedPath = r.URL.Path
w.WriteHeader(http.StatusAccepted)
}))
defer srv.Close()

// isError=true covers cases like timeout where the RIE itself constructs the error body
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
require.NoError(t, adapter.SendResult("invoke-1", []byte(`{"errorMessage":"Task timed out"}`), true))

assert.Equal(t, "/invocations/invoke-1/error", capturedPath)
}
52 changes: 52 additions & 0 deletions cmd/ls-api/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
THIS_MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
REPO_ROOT := $(abspath $(THIS_MAKEFILE_DIR)/../..)

ARCH ?= x86_64
MOCK_PORT := 48490
INTEROP_PORT := 9563
RIE_BINARY := $(REPO_ROOT)/bin/aws-lambda-rie-$(ARCH)
LS_API_BIN := $(REPO_ROOT)/bin/ls-api

# Common docker flags for start-rie and start-rie-detached.
# Uses deferred assignment (=) so $$LATEST is not expanded until recipe time,
# where Make reduces $$ -> $ before passing the line to the shell.
RIE_DOCKER_OPTS = \
--platform linux/amd64 \
--add-host=host.docker.internal:host-gateway \
-p $(INTEROP_PORT):$(INTEROP_PORT) \
-v $(RIE_BINARY):/var/rapid/init:ro \
-v $(THIS_MAKEFILE_DIR)/handler.py:/var/task/handler.py:ro \
-e LOCALSTACK_RUNTIME_ENDPOINT=http://host.docker.internal:$(MOCK_PORT) \
-e LOCALSTACK_RUNTIME_ID=test-runtime-id \
-e AWS_LAMBDA_FUNCTION_TIMEOUT=30 \
-e AWS_LAMBDA_FUNCTION_VERSION='$$LATEST' \
-e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 \
-e AWS_REGION=us-east-1 \
-e _HANDLER=handler.handler \
--entrypoint /var/rapid/init

.PHONY: build-rie build-ls-api start-mock start-rie start-rie-detached success fail smoke-test

build-rie: ## Build the RIE Linux binary via Go cross-compilation (works on macOS)
$(MAKE) -C $(REPO_ROOT) ARCH=$(ARCH) compile-lambda-linux

build-ls-api: ## Build the ls-api mock binary
go build -o $(LS_API_BIN) $(THIS_MAKEFILE_DIR)

start-mock: ## Run the ls-api LocalStack endpoint mock natively (no Docker needed)
go run $(THIS_MAKEFILE_DIR)

start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda container
docker run --rm $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12

start-rie-detached: ## Start the RIE in detached mode; prints container ID (binaries must be pre-built)
@docker run --detach $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12

success: ## Trigger a successful invocation via the mock's /success endpoint
curl -sf http://localhost:$(MOCK_PORT)/success

fail: ## Trigger an error invocation via the mock's /fail endpoint
curl -sf http://localhost:$(MOCK_PORT)/fail

smoke-test: build-rie build-ls-api ## Full e2e smoke test: start mock + RIE, verify success + error invocations, cleanup
$(THIS_MAKEFILE_DIR)/smoke-test.sh
63 changes: 63 additions & 0 deletions cmd/ls-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ls-api — LocalStack endpoint mock

A lightweight HTTP server that stands in for the LocalStack endpoint when testing the RIE in isolation, without a running LocalStack instance.

## Ports

| Port | Direction | Purpose |
|------|-----------|---------|
| `48490` | inbound | Receives callbacks from the RIE (logs, response, status) |
| `9563` | outbound | Sends invocations to the RIE's `/invoke` endpoint; must be **exposed** when the RIE runs in Docker |

## Prerequisites

- Go toolchain — to run the mock
- Docker Desktop — to run the RIE (the binary targets Linux)

## How to use

**Terminal 1 — start the mock:**

```bash
make start-mock
```

**Terminal 2 — build and start the RIE** pointing at the mock:

```bash
make start-rie
```

This cross-compiles the RIE for Linux and runs it inside a `public.ecr.aws/lambda/python:3.12` container using `handler.py` as the Lambda function. Port `9563` is exposed so the mock can deliver invocations. Once the RIE sends `POST /status/{id}/ready`, the mock automatically fires one invocation with `{"counter": 0}` and logs the result.

To build for ARM (e.g. Apple Silicon):

```bash
make start-rie ARCH=arm64
```

## Trigger endpoints

Two helper endpoints let you fire additional invocations manually after startup:

| Endpoint | Payload |
|----------|---------|
| `GET /success` | `{"counter": 0}` — expects a successful response |
| `GET /fail` | `{"counter": 0, "fail": "yes"}` — expects an error response |

```bash
make success
make fail
```

All RIE callbacks (`/invocations/*/response`, `/invocations/*/error`, `/invocations/*/logs`, `/status/*/*`) are logged to stdout and return `202 Accepted`.

## Automated smoke test

To run the full e2e smoke test non-interactively (used in CI):

```bash
make smoke-test
```

This builds both the RIE binary and the ls-api mock, starts them, verifies a successful and a failing invocation, then cleans up.
8 changes: 8 additions & 0 deletions cmd/ls-api/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import json


def handler(event, context):
print(f"Received: {json.dumps(event)}")
if event.get("fail"):
raise Exception(f"Intentional failure: fail={event['fail']}")
return {"statusCode": 200, "body": json.dumps({"echo": event})}
Loading