Skip to content

Commit 136f735

Browse files
joe4devclaude
andauthored
fix(ls-api): align mock with LocalStack API and add regression test (#101)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 06dd064 commit 136f735

13 files changed

Lines changed: 614 additions & 67 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: LocalStack Smoke Tests
2+
3+
on:
4+
push:
5+
branches: [localstack]
6+
pull_request:
7+
branches: [localstack]
8+
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
11+
cancel-in-progress: true
12+
13+
jobs:
14+
smoke-ls-mock:
15+
name: RIE ↔ LocalStack API Smoke Test
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v6
19+
20+
- name: Set up Go
21+
uses: actions/setup-go@v6
22+
with:
23+
go-version-file: go.mod
24+
25+
- name: Run smoke test
26+
run: make -C cmd/ls-mock smoke-test

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/pkg
22
/build
33
/bin
4+
aws-lambda-rie
5+
ls-mock
46
*.swp
57
*.iml
68
tags

README-LOCALSTACK.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ Refer to [debugging/README.md](./debugging/README.md) for instructions on how to
1818
| `cmd/localstack` | LocalStack customizations |
1919
| ├── `main.go` | Main entrypoint |
2020
| ├── `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`. |
21-
| `cmd/ls-api` | Mock LocalStack component for testing (likely outdated) |
21+
| `cmd/ls-mock` | Mock LocalStack component for smoke testing |
22+
| ├── [`README.md`](./cmd/ls-mock/README.md) | Instructions for LS API<->RIE smoke testing |
2223
| `debugging/` | Debug and test this Go init with LocalStack |
2324
| ├── [`README.md`](./debugging/README.md) | Instructions for building and debugging with LocalStack |
2425
| `lambda` | Original AWS implementation of the runtime emulator ideally kept untouched |
@@ -42,6 +43,6 @@ Example PR that integrates upstream changes: https://github.com/localstack/lambd
4243

4344
Document all custom changes with the following comment prefix `# LOCALSTACK CHANGES yyyy-mm-dd:`
4445

45-
* Everything in `cmd/localstack`, `cmd/ls-api`, and `.github`
46+
* Everything in `cmd/localstack`, `cmd/ls-mock`, and `.github`
4647
* `Makefile` for debugging and building with Docker
4748
* 2023-10-17: `lambda/rapidcore/server.go` pass request metadata into .Reserve(invoke.ID, invoke.TraceID, invoke.LambdaSegmentID)

cmd/localstack/custom_interop.go

Lines changed: 34 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/interop"
1919
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore"
2020
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lambda/rapidcore/standalone"
21+
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi"
2122
"github.com/go-chi/chi/v5"
2223
log "github.com/sirupsen/logrus"
2324
)
@@ -50,20 +51,35 @@ func (l *LocalStackAdapter) SendStatus(status LocalStackStatus, payload []byte)
5051
return nil
5152
}
5253

53-
// The InvokeRequest is sent by LocalStack to trigger an invocation
54-
type InvokeRequest struct {
55-
InvokeId string `json:"invoke-id"`
56-
InvokedFunctionArn string `json:"invoked-function-arn"`
57-
Payload string `json:"payload"`
58-
TraceId string `json:"trace-id"`
54+
// SendLogs posts the captured invocation logs to LocalStack.
55+
func (l *LocalStackAdapter) SendLogs(invokeId string, logs lsapi.LogResponse) error {
56+
serialized, err := json.Marshal(logs)
57+
if err != nil {
58+
return err
59+
}
60+
_, err = http.Post(l.UpstreamEndpoint+"/invocations/"+invokeId+"/logs", "application/json", bytes.NewReader(serialized))
61+
return err
5962
}
6063

61-
// The ErrorResponse is sent TO LocalStack when encountering an error
62-
type ErrorResponse struct {
63-
ErrorMessage string `json:"errorMessage"`
64-
ErrorType string `json:"errorType,omitempty"`
65-
RequestId string `json:"requestId,omitempty"`
66-
StackTrace []string `json:"stackTrace,omitempty"`
64+
// SendResult posts the invocation result body to LocalStack.
65+
// If isError is false, the body is also inspected for an "errorType" field — its
66+
// presence indicates a Lambda function error and routes the result to /error.
67+
func (l *LocalStackAdapter) SendResult(invokeId string, body []byte, isError bool) error {
68+
if !isError {
69+
var fields map[string]any
70+
if json.Unmarshal(body, &fields) == nil {
71+
_, isError = fields["errorType"]
72+
}
73+
}
74+
endpoint := "/invocations/" + invokeId + "/response"
75+
if isError {
76+
log.Infoln("Sending to /error")
77+
endpoint = "/invocations/" + invokeId + "/error"
78+
} else {
79+
log.Infoln("Sending to /response")
80+
}
81+
_, err := http.Post(l.UpstreamEndpoint+endpoint, "application/json", bytes.NewReader(body))
82+
return err
6783
}
6884

6985
func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollector *LogCollector) (server *CustomInteropServer) {
@@ -81,7 +97,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto
8197
go func() {
8298
r := chi.NewRouter()
8399
r.Post("/invoke", func(w http.ResponseWriter, r *http.Request) {
84-
invokeR := InvokeRequest{}
100+
invokeR := lsapi.InvokeRequest{}
85101
bytess, err := io.ReadAll(r.Body)
86102
if err != nil {
87103
log.Error(err)
@@ -123,7 +139,7 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto
123139
case errors.Is(err, rapidcore.ErrInvokeTimeout):
124140
log.Debugf("Got invoke timeout")
125141
isErr = true
126-
errorResponse := ErrorResponse{
142+
errorResponse := lsapi.ErrorResponse{
127143
ErrorMessage: fmt.Sprintf(
128144
"%s %s Task timed out after %d.00 seconds",
129145
time.Now().Format("2006-01-02T15:04:05Z"),
@@ -157,31 +173,11 @@ func NewCustomInteropServer(lsOpts *LsOpts, delegate interop.Server, logCollecto
157173
memorySize := GetEnvOrDie("AWS_LAMBDA_FUNCTION_MEMORY_SIZE")
158174
PrintEndReports(invokeR.InvokeId, "", memorySize, invokeStart, timeoutDuration, logCollector)
159175

160-
serializedLogs, err2 := json.Marshal(logCollector.getLogs())
161-
if err2 == nil {
162-
_, err2 = http.Post(server.upstreamEndpoint+"/invocations/"+invokeR.InvokeId+"/logs", "application/json", bytes.NewReader(serializedLogs))
163-
// TODO: handle err
164-
}
165-
166-
var errR map[string]any
167-
marshalErr := json.Unmarshal(invokeResp.Body, &errR)
168-
169-
if !isErr && marshalErr == nil {
170-
_, isErr = errR["errorType"]
176+
if err2 := server.localStackAdapter.SendLogs(invokeR.InvokeId, logCollector.getLogs()); err2 != nil {
177+
log.Error("failed to send logs to LocalStack: ", err2)
171178
}
172-
173-
if isErr {
174-
log.Infoln("Sending to /error")
175-
_, err = http.Post(server.upstreamEndpoint+"/invocations/"+invokeR.InvokeId+"/error", "application/json", bytes.NewReader(invokeResp.Body))
176-
if err != nil {
177-
log.Error(err)
178-
}
179-
} else {
180-
log.Infoln("Sending to /response")
181-
_, err = http.Post(server.upstreamEndpoint+"/invocations/"+invokeR.InvokeId+"/response", "application/json", bytes.NewReader(invokeResp.Body))
182-
if err != nil {
183-
log.Error(err)
184-
}
179+
if err2 := server.localStackAdapter.SendResult(invokeR.InvokeId, invokeResp.Body, isErr); err2 != nil {
180+
log.Error("failed to send result to LocalStack: ", err2)
185181
}
186182
}()
187183

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
10+
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// --- JSON contract tests ---
16+
17+
// TestInvokeRequestContract verifies that InvokeRequest correctly maps the JSON field names
18+
// that LocalStack sends to the RIE's /invoke endpoint (defined in
19+
// localstack-pro/localstack-core/localstack/services/lambda_/invocation/execution_environment.py).
20+
//
21+
// WARNING: The LocalStack↔RIE API contract is currently unversioned. Any change to these
22+
// field names is a silent breaking change that requires a coordinated update of both
23+
// localstack-pro and lambda-runtime-init with no safe rollback path.
24+
func TestInvokeRequestContract(t *testing.T) {
25+
raw := `{
26+
"invoke-id": "abc-123",
27+
"invoked-function-arn": "arn:aws:lambda:us-east-1:000000000000:function:my-fn",
28+
"payload": "{\"key\":\"value\"}",
29+
"trace-id": "Root=1-abc;Parent=def;Sampled=1"
30+
}`
31+
32+
var req lsapi.InvokeRequest
33+
require.NoError(t, json.Unmarshal([]byte(raw), &req))
34+
35+
assert.Equal(t, "abc-123", req.InvokeId)
36+
assert.Equal(t, "arn:aws:lambda:us-east-1:000000000000:function:my-fn", req.InvokedFunctionArn)
37+
assert.Equal(t, `{"key":"value"}`, req.Payload)
38+
assert.Equal(t, "Root=1-abc;Parent=def;Sampled=1", req.TraceId)
39+
}
40+
41+
// TestLogResponseContract verifies that LogResponse uses the "logs" JSON key expected by
42+
// LocalStack's invocation_logs handler (executor_endpoint.py).
43+
func TestLogResponseContract(t *testing.T) {
44+
raw := `{"logs":"START RequestId: abc\nEND RequestId: abc\n"}`
45+
46+
var lr lsapi.LogResponse
47+
require.NoError(t, json.Unmarshal([]byte(raw), &lr))
48+
49+
assert.Equal(t, "START RequestId: abc\nEND RequestId: abc\n", lr.Logs)
50+
}
51+
52+
// --- LocalStackAdapter.SendStatus tests ---
53+
54+
func TestSendStatus_ReadySendsToCorrectPath(t *testing.T) {
55+
var capturedReq *http.Request
56+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
57+
capturedReq = r
58+
w.WriteHeader(http.StatusAccepted)
59+
}))
60+
defer srv.Close()
61+
62+
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL, RuntimeId: "runtime-abc"}
63+
require.NoError(t, adapter.SendStatus(Ready, []byte{}))
64+
65+
assert.Equal(t, http.MethodPost, capturedReq.Method)
66+
assert.Equal(t, "/status/runtime-abc/ready", capturedReq.URL.Path)
67+
}
68+
69+
func TestSendStatus_ErrorSendsToCorrectPath(t *testing.T) {
70+
var capturedReq *http.Request
71+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+
capturedReq = r
73+
w.WriteHeader(http.StatusAccepted)
74+
}))
75+
defer srv.Close()
76+
77+
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL, RuntimeId: "runtime-abc"}
78+
require.NoError(t, adapter.SendStatus(Error, []byte(`{"errorMessage":"init failed"}`)))
79+
80+
assert.Equal(t, http.MethodPost, capturedReq.Method)
81+
assert.Equal(t, "/status/runtime-abc/error", capturedReq.URL.Path)
82+
}
83+
84+
// --- LocalStackAdapter.SendLogs tests ---
85+
86+
func TestSendLogs_SendsJSONWithLogsKey(t *testing.T) {
87+
var capturedPath string
88+
var capturedBody lsapi.LogResponse
89+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
90+
capturedPath = r.URL.Path
91+
body, _ := io.ReadAll(r.Body)
92+
_ = json.Unmarshal(body, &capturedBody)
93+
w.WriteHeader(http.StatusAccepted)
94+
}))
95+
defer srv.Close()
96+
97+
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
98+
logs := lsapi.LogResponse{Logs: "START RequestId: invoke-1\nEND RequestId: invoke-1\n"}
99+
require.NoError(t, adapter.SendLogs("invoke-1", logs))
100+
101+
assert.Equal(t, "/invocations/invoke-1/logs", capturedPath)
102+
assert.Equal(t, logs.Logs, capturedBody.Logs)
103+
}
104+
105+
// --- LocalStackAdapter.SendResult routing tests ---
106+
107+
func TestSendResult_SuccessGoesToResponseEndpoint(t *testing.T) {
108+
var capturedPath string
109+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
110+
capturedPath = r.URL.Path
111+
w.WriteHeader(http.StatusAccepted)
112+
}))
113+
defer srv.Close()
114+
115+
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
116+
require.NoError(t, adapter.SendResult("invoke-1", []byte(`{"result":"ok"}`), false))
117+
118+
assert.Equal(t, "/invocations/invoke-1/response", capturedPath)
119+
}
120+
121+
func TestSendResult_ErrorBodyGoesToErrorEndpoint(t *testing.T) {
122+
var capturedPath string
123+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
124+
capturedPath = r.URL.Path
125+
w.WriteHeader(http.StatusAccepted)
126+
}))
127+
defer srv.Close()
128+
129+
// Body contains "errorType" — LocalStack distinguishes function errors this way
130+
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
131+
errBody := []byte(`{"errorMessage":"something went wrong","errorType":"RuntimeError"}`)
132+
require.NoError(t, adapter.SendResult("invoke-1", errBody, false))
133+
134+
assert.Equal(t, "/invocations/invoke-1/error", capturedPath)
135+
}
136+
137+
func TestSendResult_ExplicitErrorFlagGoesToErrorEndpoint(t *testing.T) {
138+
var capturedPath string
139+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140+
capturedPath = r.URL.Path
141+
w.WriteHeader(http.StatusAccepted)
142+
}))
143+
defer srv.Close()
144+
145+
// isError=true covers cases like timeout where the RIE itself constructs the error body
146+
adapter := &LocalStackAdapter{UpstreamEndpoint: srv.URL}
147+
require.NoError(t, adapter.SendResult("invoke-1", []byte(`{"errorMessage":"Task timed out"}`), true))
148+
149+
assert.Equal(t, "/invocations/invoke-1/error", capturedPath)
150+
}

cmd/localstack/logs.go

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@ package main
33
import (
44
"strings"
55
"sync"
6-
)
76

8-
type LogResponse struct {
9-
Logs string `json:"logs"`
10-
}
7+
"github.com/aws/aws-lambda-runtime-interface-emulator/internal/lsapi"
8+
)
119

1210
type LogCollector struct {
1311
mutex *sync.Mutex
@@ -37,10 +35,10 @@ func (lc *LogCollector) reset() {
3735
lc.RuntimeLogs = []string{}
3836
}
3937

40-
func (lc *LogCollector) getLogs() LogResponse {
38+
func (lc *LogCollector) getLogs() lsapi.LogResponse {
4139
lc.mutex.Lock()
4240
defer lc.mutex.Unlock()
43-
response := LogResponse{
41+
response := lsapi.LogResponse{
4442
Logs: strings.Join(lc.RuntimeLogs, ""),
4543
}
4644
lc.RuntimeLogs = []string{}

cmd/ls-mock/Makefile

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
THIS_MAKEFILE_DIR := $(abspath $(dir $(lastword $(MAKEFILE_LIST))))
2+
REPO_ROOT := $(abspath $(THIS_MAKEFILE_DIR)/../..)
3+
4+
ARCH ?= x86_64
5+
MOCK_PORT := 48490
6+
INTEROP_PORT := 9563
7+
RIE_BINARY := $(REPO_ROOT)/bin/aws-lambda-rie-$(ARCH)
8+
LS_MOCK_BIN := $(REPO_ROOT)/bin/ls-mock
9+
10+
# Common docker flags for start-rie and start-rie-detached.
11+
# Uses deferred assignment (=) so $$LATEST is not expanded until recipe time,
12+
# where Make reduces $$ -> $ before passing the line to the shell.
13+
RIE_DOCKER_OPTS = \
14+
--platform linux/amd64 \
15+
--add-host=host.docker.internal:host-gateway \
16+
-p $(INTEROP_PORT):$(INTEROP_PORT) \
17+
-v $(RIE_BINARY):/var/rapid/init:ro \
18+
-v $(THIS_MAKEFILE_DIR)/handler.py:/var/task/handler.py:ro \
19+
-e LOCALSTACK_RUNTIME_ENDPOINT=http://host.docker.internal:$(MOCK_PORT) \
20+
-e LOCALSTACK_RUNTIME_ID=test-runtime-id \
21+
-e AWS_LAMBDA_FUNCTION_TIMEOUT=30 \
22+
-e AWS_LAMBDA_FUNCTION_VERSION='$$LATEST' \
23+
-e AWS_LAMBDA_FUNCTION_MEMORY_SIZE=128 \
24+
-e AWS_REGION=us-east-1 \
25+
-e _HANDLER=handler.handler \
26+
--entrypoint /var/rapid/init
27+
28+
.PHONY: build-rie build-ls-mock start-mock start-rie start-rie-detached success fail smoke-test
29+
30+
build-rie: ## Build the RIE Linux binary via Go cross-compilation (works on macOS)
31+
$(MAKE) -C $(REPO_ROOT) ARCH=$(ARCH) compile-lambda-linux
32+
33+
build-ls-mock: ## Build the ls-mock binary
34+
go build -o $(LS_MOCK_BIN) $(THIS_MAKEFILE_DIR)
35+
36+
start-mock: ## Run the ls-mock LocalStack endpoint mock natively (no Docker needed)
37+
go run $(THIS_MAKEFILE_DIR)
38+
39+
start-rie: build-rie ## Build and run the RIE inside a Docker Python Lambda container
40+
docker run --rm $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12
41+
42+
start-rie-detached: ## Start the RIE in detached mode; prints container ID (binaries must be pre-built)
43+
@docker run --detach $(RIE_DOCKER_OPTS) public.ecr.aws/lambda/python:3.12
44+
45+
success: ## Trigger a successful invocation via the mock's /success endpoint
46+
curl -sf http://localhost:$(MOCK_PORT)/success
47+
48+
fail: ## Trigger an error invocation via the mock's /fail endpoint
49+
curl -sf http://localhost:$(MOCK_PORT)/fail
50+
51+
smoke-test: build-rie build-ls-mock ## Full e2e smoke test: start mock + RIE, verify success + error invocations, cleanup
52+
$(THIS_MAKEFILE_DIR)/smoke-test.sh

0 commit comments

Comments
 (0)