Skip to content

Commit c20a9a4

Browse files
committed
Address review comments
Signed-off-by: Huy Do <huydhn@gmail.com>
1 parent 169e57f commit c20a9a4

3 files changed

Lines changed: 252 additions & 4 deletions

File tree

aws/lambda/buildkite-webhook-handler/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# Buildkite Webhook Handler Lambda
22

3-
This Lambda function receives and processes Buildkite webhook events for build and job events, saving them to DynamoDB tables.
3+
This Lambda function receives and processes Buildkite webhook events for
4+
all available Buildkite webhook events, saving them to DynamoDB tables.
5+
6+
* In the near-term, this allows vLLM maintainers to explore their CI data
7+
like time to signals or queueing time.
8+
* In the longer-term, this will provide the foundation for future UX projects
9+
on vLLM like vLLM HUD, CI failures notifications.
410

511
## Overview
612

@@ -16,7 +22,7 @@ The lambda handles two types of Buildkite webhook events:
1622
- https://buildkite.com/docs/apis/webhooks/pipelines/agent-events
1723

1824
### Build Events Table: `vllm-buildkite-build-events`
19-
- **Partition Key**: `dynamoKey` (format: `REPO_NAME/BUILD_NUMBER`)
25+
- **Partition Key**: `dynamoKey` (format: `REPO_NAME/PIPELINE_NAME/BUILD_NUMBER`)
2026
- https://buildkite.com/docs/apis/webhooks/pipelines/build-events
2127

2228
### Job Events Table: `vllm-buildkite-job-events`

aws/lambda/buildkite-webhook-handler/lambda_function.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def save_build_event(event_data: Dict[str, Any]) -> Dict[str, Any]:
6868
try:
6969
build = event_data.get("build", {})
7070
repo_name = event_data.get("pipeline", {}).get("repository", "").split("/")[-1]
71+
pipeline_name = event_data.get("pipeline", {}).get("name", "")
7172
build_number = build.get("number", "")
7273

7374
if not repo_name or not build_number:
@@ -78,10 +79,10 @@ def save_build_event(event_data: Dict[str, Any]) -> Dict[str, Any]:
7879
),
7980
}
8081

81-
dynamo_key = f"{repo_name}/{build_number}"
82+
# Buildkite build_number is only unique in a pipeline
83+
dynamo_key = f"{repo_name}/{pipeline_name}/{build_number}"
8284

8385
item = {"dynamoKey": dynamo_key, **event_data}
84-
8586
build_events_table.put_item(Item=item)
8687

8788
return {
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import json
2+
import unittest
3+
from unittest.mock import patch
4+
5+
from lambda_function import (
6+
lambda_handler,
7+
save_agent_event,
8+
save_build_event,
9+
save_job_event,
10+
)
11+
12+
13+
class TestBuildkiteWebhookHandler(unittest.TestCase):
14+
def setUp(self):
15+
# Sample agent event
16+
self.agent_event = {
17+
"event": "agent.connected",
18+
"agent": {
19+
"id": "test-agent-123",
20+
"name": "test-agent",
21+
"hostname": "test-host",
22+
},
23+
}
24+
25+
# Sample build event
26+
self.build_event = {
27+
"event": "build.finished",
28+
"build": {"number": 123, "branch": "main", "state": "passed"},
29+
"pipeline": {
30+
"repository": "https://github.com/test/repo",
31+
"name": "test-pipeline",
32+
},
33+
}
34+
35+
# Sample job event
36+
self.job_event = {
37+
"event": "job.finished",
38+
"job": {"id": "test-job-456", "name": "test-job", "state": "passed"},
39+
"pipeline": {"repository": "https://github.com/test/repo"},
40+
}
41+
42+
# Lambda event with body
43+
self.lambda_event_with_body = {"body": json.dumps(self.build_event)}
44+
45+
# Lambda event without body (direct invocation)
46+
self.lambda_event_direct = self.build_event
47+
48+
@patch("lambda_function.agent_events_table")
49+
def test_save_agent_event_success(self, mock_table):
50+
mock_table.put_item.return_value = {}
51+
52+
response = save_agent_event(self.agent_event)
53+
54+
self.assertEqual(response["statusCode"], 200)
55+
body = json.loads(response["body"])
56+
self.assertIn("Agent event saved successfully", body["message"])
57+
self.assertIn("test-agent-123", body["message"])
58+
59+
mock_table.put_item.assert_called_once()
60+
call_args = mock_table.put_item.call_args[1]
61+
self.assertEqual(call_args["Item"]["dynamoKey"], "test-agent-123")
62+
63+
@patch("lambda_function.agent_events_table")
64+
def test_save_agent_event_missing_id(self, mock_table):
65+
event_without_id = {"event": "agent.connected", "agent": {"name": "test-agent"}}
66+
67+
response = save_agent_event(event_without_id)
68+
69+
self.assertEqual(response["statusCode"], 400)
70+
body = json.loads(response["body"])
71+
self.assertEqual(body["message"], "Missing agent ID")
72+
mock_table.put_item.assert_not_called()
73+
74+
@patch("lambda_function.agent_events_table")
75+
def test_save_agent_event_dynamodb_error(self, mock_table):
76+
from botocore.exceptions import ClientError
77+
78+
mock_table.put_item.side_effect = ClientError(
79+
{"Error": {"Code": "ValidationException", "Message": "Test error"}},
80+
"PutItem",
81+
)
82+
83+
response = save_agent_event(self.agent_event)
84+
85+
self.assertEqual(response["statusCode"], 500)
86+
body = json.loads(response["body"])
87+
self.assertIn("DynamoDB error", body["message"])
88+
89+
@patch("lambda_function.build_events_table")
90+
def test_save_build_event_success(self, mock_table):
91+
mock_table.put_item.return_value = {}
92+
93+
response = save_build_event(self.build_event)
94+
95+
self.assertEqual(response["statusCode"], 200)
96+
body = json.loads(response["body"])
97+
self.assertIn("Build event saved successfully", body["message"])
98+
self.assertIn("repo/test-pipeline/123", body["message"])
99+
100+
mock_table.put_item.assert_called_once()
101+
call_args = mock_table.put_item.call_args[1]
102+
self.assertEqual(call_args["Item"]["dynamoKey"], "repo/test-pipeline/123")
103+
104+
@patch("lambda_function.build_events_table")
105+
def test_save_build_event_missing_data(self, mock_table):
106+
event_without_build_number = {
107+
"event": "build.finished",
108+
"build": {"branch": "main"},
109+
"pipeline": {
110+
"repository": "https://github.com/test/repo",
111+
"name": "test-pipeline",
112+
},
113+
}
114+
115+
response = save_build_event(event_without_build_number)
116+
117+
self.assertEqual(response["statusCode"], 400)
118+
body = json.loads(response["body"])
119+
self.assertEqual(body["message"], "Missing repository name or build number")
120+
mock_table.put_item.assert_not_called()
121+
122+
@patch("lambda_function.job_events_table")
123+
def test_save_job_event_success(self, mock_table):
124+
mock_table.put_item.return_value = {}
125+
126+
response = save_job_event(self.job_event)
127+
128+
self.assertEqual(response["statusCode"], 200)
129+
body = json.loads(response["body"])
130+
self.assertIn("Job event saved successfully", body["message"])
131+
self.assertIn("repo/test-job-456", body["message"])
132+
133+
mock_table.put_item.assert_called_once()
134+
call_args = mock_table.put_item.call_args[1]
135+
self.assertEqual(call_args["Item"]["dynamoKey"], "repo/test-job-456")
136+
137+
@patch("lambda_function.job_events_table")
138+
def test_save_job_event_missing_data(self, mock_table):
139+
event_without_job_id = {
140+
"event": "job.finished",
141+
"job": {"name": "test-job"},
142+
"pipeline": {"repository": "https://github.com/test/repo"},
143+
}
144+
145+
response = save_job_event(event_without_job_id)
146+
147+
self.assertEqual(response["statusCode"], 400)
148+
body = json.loads(response["body"])
149+
self.assertEqual(body["message"], "Missing repository name or job ID")
150+
mock_table.put_item.assert_not_called()
151+
152+
@patch("lambda_function.save_build_event")
153+
def test_lambda_handler_with_body(self, mock_save_build):
154+
mock_save_build.return_value = {
155+
"statusCode": 200,
156+
"body": json.dumps({"message": "Success"}),
157+
}
158+
159+
response = lambda_handler(self.lambda_event_with_body, {})
160+
161+
self.assertEqual(response["statusCode"], 200)
162+
mock_save_build.assert_called_once_with(self.build_event)
163+
164+
@patch("lambda_function.save_build_event")
165+
def test_lambda_handler_direct_event(self, mock_save_build):
166+
mock_save_build.return_value = {
167+
"statusCode": 200,
168+
"body": json.dumps({"message": "Success"}),
169+
}
170+
171+
response = lambda_handler(self.lambda_event_direct, {})
172+
173+
self.assertEqual(response["statusCode"], 200)
174+
mock_save_build.assert_called_once_with(self.build_event)
175+
176+
@patch("lambda_function.save_agent_event")
177+
def test_lambda_handler_agent_event(self, mock_save_agent):
178+
agent_lambda_event = {"body": json.dumps(self.agent_event)}
179+
mock_save_agent.return_value = {
180+
"statusCode": 200,
181+
"body": json.dumps({"message": "Success"}),
182+
}
183+
184+
response = lambda_handler(agent_lambda_event, {})
185+
186+
self.assertEqual(response["statusCode"], 200)
187+
mock_save_agent.assert_called_once_with(self.agent_event)
188+
189+
@patch("lambda_function.save_job_event")
190+
def test_lambda_handler_job_event(self, mock_save_job):
191+
job_lambda_event = {"body": json.dumps(self.job_event)}
192+
mock_save_job.return_value = {
193+
"statusCode": 200,
194+
"body": json.dumps({"message": "Success"}),
195+
}
196+
197+
response = lambda_handler(job_lambda_event, {})
198+
199+
self.assertEqual(response["statusCode"], 200)
200+
mock_save_job.assert_called_once_with(self.job_event)
201+
202+
def test_lambda_handler_missing_event_type(self):
203+
event_without_type = {"body": json.dumps({"some": "data"})}
204+
205+
response = lambda_handler(event_without_type, {})
206+
207+
self.assertEqual(response["statusCode"], 400)
208+
body = json.loads(response["body"])
209+
self.assertEqual(body["message"], "Missing event type in webhook payload")
210+
211+
def test_lambda_handler_unsupported_event_type(self):
212+
unsupported_event = {"body": json.dumps({"event": "unsupported.event"})}
213+
214+
response = lambda_handler(unsupported_event, {})
215+
216+
self.assertEqual(response["statusCode"], 400)
217+
body = json.loads(response["body"])
218+
self.assertEqual(body["message"], "Unsupported event type: unsupported.event")
219+
220+
def test_lambda_handler_invalid_json(self):
221+
invalid_json_event = {"body": "invalid json"}
222+
223+
response = lambda_handler(invalid_json_event, {})
224+
225+
self.assertEqual(response["statusCode"], 400)
226+
body = json.loads(response["body"])
227+
self.assertIn("Invalid JSON payload", body["message"])
228+
229+
@patch("lambda_function.save_build_event")
230+
def test_lambda_handler_unexpected_error(self, mock_save_build):
231+
mock_save_build.side_effect = Exception("Unexpected error")
232+
233+
response = lambda_handler({"body": json.dumps(self.build_event)}, {})
234+
235+
self.assertEqual(response["statusCode"], 500)
236+
body = json.loads(response["body"])
237+
self.assertIn("Unexpected error", body["message"])
238+
239+
240+
if __name__ == "__main__":
241+
unittest.main()

0 commit comments

Comments
 (0)