Skip to content

Commit 8f17343

Browse files
authored
Add EventBridge Lambda target delivery (#146)
* Add EventBridge Lambda target delivery - Routes EventBridge Lambda targets through the Go Lambda invocation path with CloudWatch Logs recording. - Keeps local Node.js handler execution behind the existing signed localhost allow-local gate. - Adds Go and AWS SDK coverage plus docs for EventBridge-to-Lambda delivery. * Update AWS skill EventBridge Lambda docs * Document EventBridge Lambda known key gate * Format AWS EventBridge e2e test
1 parent e145848 commit 8f17343

8 files changed

Lines changed: 305 additions & 38 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -778,12 +778,12 @@ In the native Go runtime, `@aws-sdk/client-sns` v3 can use the `/sns/` endpoint
778778
- `AddPermission`, `RemovePermission`
779779

780780
### EventBridge
781-
In the native Go runtime, `@aws-sdk/client-eventbridge` v3 can use the `/events/` endpoint directly. The SDK sends `X-Amz-Target: AWSEvents.<Action>` JSON requests and receives JSON responses. Matching events can be delivered to SQS queues and SNS topics.
781+
In the native Go runtime, `@aws-sdk/client-eventbridge` v3 can use the `/events/` endpoint directly. The SDK sends `X-Amz-Target: AWSEvents.<Action>` JSON requests and receives JSON responses. Matching events can be delivered to SQS queues, SNS topics, and Lambda functions. Lambda targets create CloudWatch Logs entries; zipped Node.js handlers run only when `npx emulate` is started with `--allow-local-lambda` and the EventBridge request uses a direct localhost endpoint signed by a known AWS access key.
782782

783783
- `CreateEventBus`, `DeleteEventBus`, `ListEventBuses`
784784
- `PutRule`, `DescribeRule`, `ListRules`, `DeleteRule`, `EnableRule`, `DisableRule`
785-
- `PutTargets`, `ListTargetsByRule`, `RemoveTargets`
786-
- `PutEvents` with rule pattern matching
785+
- `PutTargets`, `ListTargetsByRule`, `RemoveTargets` for SQS, SNS, and Lambda targets
786+
- `PutEvents` with rule pattern matching and target delivery
787787
- `TagResource`, `UntagResource`, `ListTagsForResource`
788788

789789
### DynamoDB

apps/web/app/docs/aws/page.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,12 @@ In the native Go runtime, `@aws-sdk/client-sns` v3 can use the `/sns/` endpoint
6060

6161
## EventBridge
6262

63-
In the native Go runtime, `@aws-sdk/client-eventbridge` v3 can use the `/events/` endpoint directly. The SDK sends `X-Amz-Target: AWSEvents.<Action>` JSON requests and receives JSON responses. Matching events can be delivered to SQS queues and SNS topics.
63+
In the native Go runtime, `@aws-sdk/client-eventbridge` v3 can use the `/events/` endpoint directly. The SDK sends `X-Amz-Target: AWSEvents.<Action>` JSON requests and receives JSON responses. Matching events can be delivered to SQS queues, SNS topics, and Lambda functions. Lambda targets create CloudWatch Logs entries; zipped Node.js handlers run only when `npx emulate` is started with `--allow-local-lambda` and the EventBridge request uses a direct localhost endpoint signed by a known AWS access key.
6464

6565
- `CreateEventBus` / `DeleteEventBus` / `ListEventBuses` - event bus lifecycle and discovery
6666
- `PutRule` / `DescribeRule` / `ListRules` / `DeleteRule` / `EnableRule` / `DisableRule` - rule lifecycle and state
67-
- `PutTargets` / `ListTargetsByRule` / `RemoveTargets` - SQS and SNS target management
68-
- `PutEvents` - publish events with rule pattern matching
67+
- `PutTargets` / `ListTargetsByRule` / `RemoveTargets` - SQS, SNS, and Lambda target management
68+
- `PutEvents` - publish events with rule pattern matching and target delivery
6969
- `TagResource` / `UntagResource` / `ListTagsForResource` - event bus and rule tags
7070

7171
## DynamoDB

internal/services/aws/eventbridge/handler.go

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
corestore "github.com/vercel-labs/emulate/internal/core/store"
1818
"github.com/vercel-labs/emulate/internal/services/aws/gateway"
19+
awslambda "github.com/vercel-labs/emulate/internal/services/aws/lambda"
1920
"github.com/vercel-labs/emulate/internal/services/aws/protocols"
2021
)
2122

@@ -30,24 +31,31 @@ type eventBusRef struct {
3031
}
3132

3233
type Handler struct {
33-
EventBuses *corestore.Collection
34-
EventRules *corestore.Collection
35-
EventTargets *corestore.Collection
36-
EventDeliveries *corestore.Collection
37-
SQSQueues *corestore.Collection
38-
SQSMessages *corestore.Collection
39-
SNSTopics *corestore.Collection
40-
SNSSubscriptions *corestore.Collection
41-
SNSDeliveries *corestore.Collection
42-
AccountID string
43-
Region string
44-
Now func() time.Time
45-
IDGenerator func(string) string
34+
EventBuses *corestore.Collection
35+
EventRules *corestore.Collection
36+
EventTargets *corestore.Collection
37+
EventDeliveries *corestore.Collection
38+
SQSQueues *corestore.Collection
39+
SQSMessages *corestore.Collection
40+
SNSTopics *corestore.Collection
41+
SNSSubscriptions *corestore.Collection
42+
SNSDeliveries *corestore.Collection
43+
LambdaFunctions *corestore.Collection
44+
LambdaVersions *corestore.Collection
45+
LambdaAliases *corestore.Collection
46+
LogGroups *corestore.Collection
47+
LogStreams *corestore.Collection
48+
LogEvents *corestore.Collection
49+
LambdaLocalCodeExecution bool
50+
AccountID string
51+
Region string
52+
Now func() time.Time
53+
IDGenerator func(string) string
4654
}
4755

4856
var fallbackIDCounter atomic.Uint64
4957

50-
func (h *Handler) Handle(_ *http.Request, ctx gateway.AwsRequestContext) protocols.ErrorResponse {
58+
func (h *Handler) Handle(req *http.Request, ctx gateway.AwsRequestContext) protocols.ErrorResponse {
5159
requestID := ctx.RequestID
5260
if requestID == "" {
5361
requestID = h.generateID("req")
@@ -79,7 +87,7 @@ func (h *Handler) Handle(_ *http.Request, ctx gateway.AwsRequestContext) protoco
7987
case "RemoveTargets":
8088
response = h.removeTargets(ctx, requestID)
8189
case "PutEvents":
82-
response = h.putEvents(ctx, requestID)
90+
response = h.putEvents(req, ctx, requestID)
8391
case "TagResource":
8492
response = h.tagResource(ctx, requestID)
8593
case "UntagResource":
@@ -372,7 +380,7 @@ func (h *Handler) removeTargets(ctx gateway.AwsRequestContext, requestID string)
372380
return jsonResponse(http.StatusOK, map[string]any{"FailedEntryCount": 0, "FailedEntries": []any{}})
373381
}
374382

375-
func (h *Handler) putEvents(ctx gateway.AwsRequestContext, requestID string) protocols.ErrorResponse {
383+
func (h *Handler) putEvents(req *http.Request, ctx gateway.AwsRequestContext, requestID string) protocols.ErrorResponse {
376384
entries := mapSlice(ctx.Input["Entries"])
377385
if len(entries) == 0 {
378386
return h.validation("Entries is required.", requestID)
@@ -421,7 +429,7 @@ func (h *Handler) putEvents(ctx gateway.AwsRequestContext, requestID string) pro
421429
"resources": stringSlice(entry["Resources"]),
422430
"detail": detail,
423431
}
424-
h.deliverEvent(ctx, busName, eventID, event)
432+
h.deliverEvent(req, ctx, busName, eventID, event)
425433
out = append(out, map[string]any{"EventId": eventID})
426434
}
427435
return jsonResponse(http.StatusOK, map[string]any{"FailedEntryCount": failed, "Entries": out})
@@ -457,7 +465,7 @@ func (h *Handler) listTagsForResource(ctx gateway.AwsRequestContext, requestID s
457465
return jsonResponse(http.StatusOK, map[string]any{"Tags": recordList(record["tags"])})
458466
}
459467

460-
func (h *Handler) deliverEvent(ctx gateway.AwsRequestContext, busName string, eventID string, event map[string]any) {
468+
func (h *Handler) deliverEvent(req *http.Request, ctx gateway.AwsRequestContext, busName string, eventID string, event map[string]any) {
461469
for _, rule := range h.EventRules.FindBy("event_bus_name", busName) {
462470
if !h.sameScope(ctx, rule) || stringField(rule, "state") == "DISABLED" || !matchesPattern(rule, event) {
463471
continue
@@ -473,6 +481,8 @@ func (h *Handler) deliverEvent(ctx gateway.AwsRequestContext, busName string, ev
473481
status = "DELIVERED"
474482
case h.deliverToSNS(ctx, target, body):
475483
status = "DELIVERED"
484+
case h.deliverToLambda(req, ctx, target, body):
485+
status = "DELIVERED"
476486
}
477487
h.EventDeliveries.Insert(corestore.Record{
478488
"account_id": h.accountID(ctx),
@@ -572,6 +582,27 @@ func (h *Handler) deliverToSNS(ctx gateway.AwsRequestContext, target corestore.R
572582
return delivered
573583
}
574584

585+
func (h *Handler) deliverToLambda(req *http.Request, ctx gateway.AwsRequestContext, target corestore.Record, body string) bool {
586+
if h.LambdaFunctions == nil {
587+
return false
588+
}
589+
lambdaHandler := awslambda.Handler{
590+
Functions: h.LambdaFunctions,
591+
Versions: h.LambdaVersions,
592+
Aliases: h.LambdaAliases,
593+
LogGroups: h.LogGroups,
594+
LogStreams: h.LogStreams,
595+
LogEvents: h.LogEvents,
596+
AccountID: h.AccountID,
597+
Region: h.Region,
598+
AllowLocalCodeExecution: h.LambdaLocalCodeExecution,
599+
Now: h.Now,
600+
IDGenerator: h.IDGenerator,
601+
}
602+
_, ok := lambdaHandler.InvokeFromEventSource(req, ctx, stringField(target, "arn"), []byte(body), h.generateID("req"), "EventBridge")
603+
return ok
604+
}
605+
575606
func (h *Handler) snsSQSBody(topic corestore.Record, subscription corestore.Record, messageID string, message string) string {
576607
if strings.EqualFold(stringMapField(subscription, "attributes")["RawMessageDelivery"], "true") {
577608
return message

internal/services/aws/lambda/handler.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ type functionIdentifier struct {
5858
ARN bool
5959
}
6060

61+
type EventSourceInvokeResult struct {
62+
ExecutedVersion string
63+
FunctionError string
64+
LocalExecuted bool
65+
LogStreamName string
66+
}
67+
6168
var fallbackIDCounter atomic.Uint64
6269

6370
func (h *Handler) Handle(req *http.Request, ctx gateway.AwsRequestContext) protocols.ErrorResponse {
@@ -419,6 +426,37 @@ func (h *Handler) invoke(req *http.Request, ctx gateway.AwsRequestContext, route
419426
return lambdaBodyResponse(http.StatusOK, payload, headers)
420427
}
421428

429+
func (h *Handler) InvokeFromEventSource(req *http.Request, ctx gateway.AwsRequestContext, targetARN string, payload []byte, requestID string, source string) (EventSourceInvokeResult, bool) {
430+
fn, ok := h.findFunction(ctx, targetARN)
431+
if !ok {
432+
return EventSourceInvokeResult{}, false
433+
}
434+
parsed := parseFunctionIdentifier(targetARN)
435+
invoked, executedVersion, _, ok := h.recordForQualifier(ctx, fn, parsed.Qualifier, requestID)
436+
if !ok {
437+
return EventSourceInvokeResult{}, false
438+
}
439+
if len(strings.TrimSpace(string(payload))) == 0 {
440+
payload = []byte("{}")
441+
}
442+
logStreamName := h.lambdaLogStreamName(executedVersion)
443+
source = strings.TrimSpace(source)
444+
if source == "" {
445+
source = "event source"
446+
}
447+
logLines := []string{"Lambda " + source + " invoke accepted RequestId: " + requestID}
448+
result := EventSourceInvokeResult{ExecutedVersion: executedVersion, LogStreamName: logStreamName}
449+
if h.localCodeExecutionAllowed(req, ctx) {
450+
if local, ran := h.invokeLocalNode(ctx, invoked, executedVersion, invokedFunctionARN(fn, parsed.Qualifier), payload, requestID, logStreamName); ran {
451+
logLines = local.Logs
452+
result.FunctionError = local.FunctionError
453+
result.LocalExecuted = true
454+
}
455+
}
456+
h.recordInvocation(ctx, invoked, requestID, "Event", executedVersion, logStreamName, logLines)
457+
return result, true
458+
}
459+
422460
func (h *Handler) localCodeExecutionAllowed(req *http.Request, ctx gateway.AwsRequestContext) bool {
423461
if !h.AllowLocalCodeExecution || ctx.Auth.Status != auth.StatusKnown || ctx.Auth.Credential == nil {
424462
return false

internal/services/aws/service.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -154,17 +154,24 @@ func New(options Options) *Service {
154154
Region: defaultRegion,
155155
},
156156
events: awsevents.Handler{
157-
EventBuses: awsStore.EventBuses,
158-
EventRules: awsStore.EventRules,
159-
EventTargets: awsStore.EventTargets,
160-
EventDeliveries: awsStore.EventDeliveries,
161-
SQSQueues: awsStore.SQSQueues,
162-
SQSMessages: awsStore.SQSMessages,
163-
SNSTopics: awsStore.SNSTopics,
164-
SNSSubscriptions: awsStore.SNSSubscriptions,
165-
SNSDeliveries: awsStore.SNSDeliveries,
166-
AccountID: defaultAccountID,
167-
Region: defaultRegion,
157+
EventBuses: awsStore.EventBuses,
158+
EventRules: awsStore.EventRules,
159+
EventTargets: awsStore.EventTargets,
160+
EventDeliveries: awsStore.EventDeliveries,
161+
SQSQueues: awsStore.SQSQueues,
162+
SQSMessages: awsStore.SQSMessages,
163+
SNSTopics: awsStore.SNSTopics,
164+
SNSSubscriptions: awsStore.SNSSubscriptions,
165+
SNSDeliveries: awsStore.SNSDeliveries,
166+
LambdaFunctions: awsStore.LambdaFunctions,
167+
LambdaVersions: awsStore.LambdaVersions,
168+
LambdaAliases: awsStore.LambdaAliases,
169+
LogGroups: awsStore.LogGroups,
170+
LogStreams: awsStore.LogStreams,
171+
LogEvents: awsStore.LogEvents,
172+
LambdaLocalCodeExecution: options.LambdaLocalCodeExecution,
173+
AccountID: defaultAccountID,
174+
Region: defaultRegion,
168175
},
169176
logs: awslogs.Handler{
170177
LogGroups: awsStore.LogGroups,

internal/services/aws/service_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1711,6 +1711,128 @@ func TestServiceHandlesEventBridgeRuleAndSQSTarget(t *testing.T) {
17111711
}
17121712
}
17131713

1714+
func TestServiceHandlesEventBridgeLambdaTarget(t *testing.T) {
1715+
handler := newTestHandler()
1716+
1717+
create := executeAWSLambdaRequest(t, handler, http.MethodPost, "/2015-03-31/functions", map[string]any{
1718+
"FunctionName": "eventbridge-target",
1719+
"Runtime": "python3.12",
1720+
"Role": "arn:aws:iam::123456789012:role/lambda-execution-role",
1721+
"Handler": "index.handler",
1722+
"Code": map[string]any{"ZipFile": base64.StdEncoding.EncodeToString([]byte("stub"))},
1723+
})
1724+
if create.Code != http.StatusCreated {
1725+
t.Fatalf("create lambda status = %d, body = %s", create.Code, create.Body.String())
1726+
}
1727+
var created struct {
1728+
FunctionArn string `json:"FunctionArn"`
1729+
}
1730+
decodeJSONBody(t, create, &created)
1731+
1732+
res := executeAWSEventBridgeRequest(t, handler, "PutRule", map[string]any{
1733+
"Name": "lambda-orders",
1734+
"EventPattern": `{"source":["app.orders"],"detail-type":["OrderCreated"]}`,
1735+
})
1736+
if res.Code != http.StatusOK {
1737+
t.Fatalf("put rule status = %d, body = %s", res.Code, res.Body.String())
1738+
}
1739+
1740+
res = executeAWSEventBridgeRequest(t, handler, "PutTargets", map[string]any{
1741+
"Rule": "lambda-orders",
1742+
"Targets": []map[string]any{{
1743+
"Id": "lambda",
1744+
"Arn": created.FunctionArn,
1745+
}},
1746+
})
1747+
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"FailedEntryCount":0`) {
1748+
t.Fatalf("put targets status = %d, body = %s", res.Code, res.Body.String())
1749+
}
1750+
1751+
res = executeAWSEventBridgeRequest(t, handler, "PutEvents", map[string]any{
1752+
"Entries": []map[string]any{{"Source": "app.orders", "DetailType": "OrderCreated", "Detail": `{"id":"ord_1"}`}},
1753+
})
1754+
if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"FailedEntryCount":0`) {
1755+
t.Fatalf("put events status = %d, body = %s", res.Code, res.Body.String())
1756+
}
1757+
1758+
logs := executeAWSLogsRequest(t, handler, "FilterLogEvents", map[string]any{"logGroupName": "/aws/lambda/eventbridge-target", "filterPattern": "EventBridge"})
1759+
if logs.Code != http.StatusOK {
1760+
t.Fatalf("filter logs status = %d, body = %s", logs.Code, logs.Body.String())
1761+
}
1762+
var logBody struct {
1763+
Events []struct {
1764+
Message string `json:"message"`
1765+
} `json:"events"`
1766+
}
1767+
decodeJSONBody(t, logs, &logBody)
1768+
if len(logBody.Events) == 0 || !strings.Contains(logBody.Events[0].Message, "Lambda EventBridge invoke accepted") {
1769+
t.Fatalf("unexpected lambda target logs: %#v", logBody.Events)
1770+
}
1771+
}
1772+
1773+
func TestServiceRunsEventBridgeLambdaTargetWithLocalNodeHandler(t *testing.T) {
1774+
if _, err := exec.LookPath("node"); err != nil {
1775+
t.Skip("node is required for local Lambda Node.js runner coverage")
1776+
}
1777+
handler := newTestHandlerWithOptions(Options{LambdaLocalCodeExecution: true})
1778+
const accessKeyID = "AKIAIOSFODNN7EXAMPLE"
1779+
zipFile := zipLambdaSource(t, map[string]string{"index.js": `exports.handler = async (event, context) => {
1780+
console.log("eventbridge lambda", event.detail.name, context.functionName);
1781+
return { ok: true };
1782+
};
1783+
`})
1784+
1785+
create := executeAWSLambdaRequest(t, handler, http.MethodPost, "/2015-03-31/functions", map[string]any{
1786+
"FunctionName": "eventbridge-local-target",
1787+
"Runtime": "nodejs22.x",
1788+
"Role": "arn:aws:iam::123456789012:role/lambda-execution-role",
1789+
"Handler": "index.handler",
1790+
"Code": map[string]any{"ZipFile": zipFile},
1791+
})
1792+
if create.Code != http.StatusCreated {
1793+
t.Fatalf("create lambda status = %d, body = %s", create.Code, create.Body.String())
1794+
}
1795+
var created struct {
1796+
FunctionArn string `json:"FunctionArn"`
1797+
}
1798+
decodeJSONBody(t, create, &created)
1799+
1800+
res := executeAWSEventBridgeRequestWithAccessKey(t, handler, "PutRule", map[string]any{
1801+
"Name": "lambda-local-orders",
1802+
"EventPattern": `{"source":["app.orders"],"detail-type":["OrderCreated"]}`,
1803+
}, accessKeyID)
1804+
if res.Code != http.StatusOK {
1805+
t.Fatalf("put rule status = %d, body = %s", res.Code, res.Body.String())
1806+
}
1807+
res = executeAWSEventBridgeRequestWithAccessKey(t, handler, "PutTargets", map[string]any{
1808+
"Rule": "lambda-local-orders",
1809+
"Targets": []map[string]any{{"Id": "lambda", "Arn": created.FunctionArn}},
1810+
}, accessKeyID)
1811+
if res.Code != http.StatusOK {
1812+
t.Fatalf("put targets status = %d, body = %s", res.Code, res.Body.String())
1813+
}
1814+
res = executeAWSEventBridgeRequestWithAccessKey(t, handler, "PutEvents", map[string]any{
1815+
"Entries": []map[string]any{{"Source": "app.orders", "DetailType": "OrderCreated", "Detail": `{"name":"Ada"}`}},
1816+
}, accessKeyID)
1817+
if res.Code != http.StatusOK {
1818+
t.Fatalf("put events status = %d, body = %s", res.Code, res.Body.String())
1819+
}
1820+
1821+
logs := executeAWSLogsRequest(t, handler, "FilterLogEvents", map[string]any{"logGroupName": "/aws/lambda/eventbridge-local-target", "filterPattern": "eventbridge lambda"})
1822+
if logs.Code != http.StatusOK {
1823+
t.Fatalf("filter logs status = %d, body = %s", logs.Code, logs.Body.String())
1824+
}
1825+
var logBody struct {
1826+
Events []struct {
1827+
Message string `json:"message"`
1828+
} `json:"events"`
1829+
}
1830+
decodeJSONBody(t, logs, &logBody)
1831+
if len(logBody.Events) != 1 || !strings.Contains(logBody.Events[0].Message, "eventbridge lambda Ada eventbridge-local-target") {
1832+
t.Fatalf("unexpected lambda target logs: %#v", logBody.Events)
1833+
}
1834+
}
1835+
17141836
func TestServiceHandlesEventBridgeCustomBusTagsAndSNSTarget(t *testing.T) {
17151837
handler := newTestHandler()
17161838

@@ -5301,6 +5423,7 @@ func executeAWSEventBridgeRequestWithAccessKey(t *testing.T, handler http.Handle
53015423
t.Fatal(err)
53025424
}
53035425
req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/events/", bytes.NewReader(raw))
5426+
req.RemoteAddr = "127.0.0.1:1234"
53045427
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
53055428
req.Header.Set("X-Amz-Target", "AWSEvents."+action)
53065429
req.Header.Set("X-Access-Key", accessKeyID)

0 commit comments

Comments
 (0)