blob: 2e3681dde43db7953d635648536d18f8a3bbe1fd [file] [log] [blame]
Austin Schuhfd8431e2020-12-27 19:35:04 -08001package main
2
3import (
4 "bufio"
5 "bytes"
6 "encoding/json"
7 "flag"
8 "fmt"
9 "github.com/buildkite/go-buildkite/buildkite"
10 "io/ioutil"
11 "log"
12 "net/http"
13 "os/exec"
14 "regexp"
15 "strings"
16 "sync"
Austin Schuh07450432021-03-21 20:29:07 -070017 "time"
Austin Schuhfd8431e2020-12-27 19:35:04 -080018)
19
20type Commit struct {
21 Sha1 string
22 ChangeId string
23 ChangeNumber int
24 Patchset int
25}
26
27type State struct {
28 // This mutex needs to be locked across anything which generates a uuid or accesses Commits.
29 mu sync.Mutex
30 // Mapping from build UUID from buildkite to the Commit information which triggered it.
31 Commits map[string]Commit
32 User string
33 Key string
34 // Webhook token expected to service requests
35 Token string
36}
37
38type Approval struct {
39 Type string `json:"type"`
40 Description string `json:"description"`
41 Value string `json:"value"`
42 OldValue string `json:"oldValue"`
43}
44
45type User struct {
46 Name string `json:"name"`
47 Email string `json:"email,omitempty"`
48 Username string `json:"username,omitempty"`
49}
50
51type PatchSet struct {
52 Number int `json:"number"`
53 Revision string `json:"revision"`
54 Parents []string `json:"parents"`
55 Ref string `json:"ref"`
56 Uploader User `json:"uploader"`
57 CreatedOn int `json:"createdOn"`
58 Author User `json:"author"`
59 Kind string `json:"kind,omitempty"`
60 SizeInsertions int `json:"sizeInsertions,omitempty"`
61 SizeDeletions int `json:"sizeDeletions,omitempty"`
62}
63
64type Change struct {
65 Project string `json:"project"`
66 Branch string `json:"branch"`
67 ID string `json:"id"`
68 Number int `json:"number"`
69 Subject string `json:"subject"`
70 Owner User `json:"owner"`
71 URL string `json:"url"`
72 CommitMessage string `json:"commitMessage"`
73 CreatedOn int `json:"createdOn"`
74 Status string `json:"status"`
75}
76
77type ChangeKey struct {
78 ID string `json:"id"`
79}
80
81type RefUpdate struct {
82 OldRev string `json:"oldRev"`
83 NewRev string `json:"newRev"`
84 RefName string `json:"refName"`
85 Project string `json:"project"`
86}
87
88type EventInfo struct {
89 Author *User `json:"author"`
90 Uploader *User `json:"uploader"`
Austin Schuh07450432021-03-21 20:29:07 -070091 Reviewer *User `json:"reviewer"`
Austin Schuh68d28912021-03-21 21:59:28 -070092 Adder *User `json:"adder"`
93 Remover *User `json:"remover"`
Austin Schuhfd8431e2020-12-27 19:35:04 -080094 Submitter User `json:"submitter,omitempty"`
Austin Schuh68d28912021-03-21 21:59:28 -070095 NewRev string `json:"newRev,omitempty"`
96 Ref string `json:"ref,omitempty"`
97 TargetNode string `json:"targetNode,omitempty"`
Austin Schuhfd8431e2020-12-27 19:35:04 -080098 Approvals []Approval `json:"approvals,omitempty"`
99 Comment string `json:"comment,omitempty"`
100 PatchSet *PatchSet `json:"patchSet"`
101 Change *Change `json:"change"`
102 Project string `json:"project"`
103 RefName string `json:"refName"`
104 ChangeKey ChangeKey `json:"changeKey"`
105 RefUpdate *RefUpdate `json:"refUpdate"`
106 Type string `json:"type"`
107 EventCreatedOn int `json:"eventCreatedOn"`
108}
109
110// Simple application to poll Gerrit for events and trigger builds on buildkite when one happens.
111
112// Handles a gerrit event and triggers buildkite accordingly.
113func (s *State) handleEvent(eventInfo EventInfo, client *buildkite.Client) {
114 // Only work on 971-Robot-Code
115 if eventInfo.Project != "971-Robot-Code" {
116 log.Printf("Ignoring project: '%s'\n", eventInfo.Project)
117 return
118 }
119
120 // Find the change id, change number, patchset revision
121 if eventInfo.Change == nil {
122 log.Println("Failed to find Change")
123 return
124 }
125
126 if eventInfo.PatchSet == nil {
127 log.Println("Failed to find Change")
128 return
129 }
130
131 log.Printf("Got a matching change of %s %s %d,%d\n",
132 eventInfo.Change.ID, eventInfo.PatchSet.Revision, eventInfo.Change.Number, eventInfo.PatchSet.Number)
133
Austin Schuh07450432021-03-21 20:29:07 -0700134 for {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800135
Austin Schuh07450432021-03-21 20:29:07 -0700136 // Triggering a build creates a UUID, and we can see events back from the webhook before the command returns. Lock across the command so nothing access Commits while the new UUID is being added.
137 s.mu.Lock()
Austin Schuhfd8431e2020-12-27 19:35:04 -0800138
Austin Schuh07450432021-03-21 20:29:07 -0700139 var user *User
140 if eventInfo.Author != nil {
141 user = eventInfo.Author
142 } else if eventInfo.Uploader != nil {
143 user = eventInfo.Uploader
Austin Schuhfd8431e2020-12-27 19:35:04 -0800144 } else {
Austin Schuh07450432021-03-21 20:29:07 -0700145 log.Fatalf("Failed to find Author or Uploader")
Austin Schuhfd8431e2020-12-27 19:35:04 -0800146 }
147
Austin Schuh07450432021-03-21 20:29:07 -0700148 // Trigger the build.
149 if build, _, err := client.Builds.Create(
150 "spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
151 Commit: eventInfo.PatchSet.Revision,
152 Branch: eventInfo.Change.ID,
153 Author: buildkite.Author{
154 Name: user.Name,
155 Email: user.Email,
156 },
157 Env: map[string]string{
158 "GERRIT_CHANGE_NUMBER": fmt.Sprintf("%d", eventInfo.Change.Number),
159 "GERRIT_PATCH_NUMBER": fmt.Sprintf("%d", eventInfo.PatchSet.Number),
160 },
161 }); err == nil {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800162
Austin Schuh07450432021-03-21 20:29:07 -0700163 if build.ID != nil {
164 log.Printf("Scheduled build %s\n", *build.ID)
165 s.Commits[*build.ID] = Commit{
166 Sha1: eventInfo.PatchSet.Revision,
167 ChangeId: eventInfo.Change.ID,
168 ChangeNumber: eventInfo.Change.Number,
169 Patchset: eventInfo.PatchSet.Number,
170 }
171 }
172 s.mu.Unlock()
173
174 if data, err := json.MarshalIndent(build, "", "\t"); err != nil {
175 log.Fatalf("json encode failed: %s", err)
176 } else {
177 log.Printf("%s\n", string(data))
178 }
179
180 // Now remove the verified from Gerrit and post the link.
181 cmd := exec.Command("ssh",
182 "-p",
183 "29418",
184 "-i",
185 s.Key,
186 s.User+"@software.frc971.org",
187 "gerrit",
188 "review",
189 "-m",
190 fmt.Sprintf("\"Build Started: %s\"", *build.WebURL),
191 "--verified",
192 "0",
193 fmt.Sprintf("%d,%d", eventInfo.Change.Number, eventInfo.PatchSet.Number))
194
195 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build Started: %s\"' --verified 0 %d,%d' and waiting for it to finish...",
196 s.Key, s.User,
197 *build.WebURL, eventInfo.Change.Number, eventInfo.PatchSet.Number)
198 if err := cmd.Run(); err != nil {
199 log.Printf("Command failed with error: %v", err)
200 }
201 return
202 } else {
203 s.mu.Unlock()
204 log.Printf("Failed to trigger build: %s", err)
205 log.Printf("Trying again in 30 seconds")
206 time.Sleep(30 * time.Second)
Austin Schuhfd8431e2020-12-27 19:35:04 -0800207 }
Austin Schuhfd8431e2020-12-27 19:35:04 -0800208 }
209
210}
211
Austin Schuh07450432021-03-21 20:29:07 -0700212type BuildkiteChange struct {
213 ID string `json:"id,omitempty"`
214 Number int `json:"number,omitempty"`
215 URL string `json:"url,omitempty"`
216}
217
Austin Schuhfd8431e2020-12-27 19:35:04 -0800218type Build struct {
Austin Schuh07450432021-03-21 20:29:07 -0700219 ID string `json:"id,omitempty"`
220 GraphqlId string `json:"graphql_id,omitempty"`
221 URL string `json:"url,omitempty"`
222 WebURL string `json:"web_url,omitempty"`
223 Number int `json:"number,omitempty"`
224 State string `json:"state,omitempty"`
225 Blocked bool `json:"blocked,omitempty"`
226 BlockedState string `json:"blocked_state,omitempty"`
227 Message string `json:"message,omitempty"`
228 Commit string `json:"commit"`
229 Branch string `json:"branch"`
230 Source string `json:"source,omitempty"`
231 CreatedAt string `json:"created_at,omitempty"`
232 ScheduledAt string `json:"scheduled_at,omitempty"`
233 StartedAt string `json:"started_at,omitempty"`
234 FinishedAt string `json:"finished_at,omitempty"`
235 RebuiltFrom *BuildkiteChange `json:"rebuilt_from,omitempty"`
Austin Schuhfd8431e2020-12-27 19:35:04 -0800236}
237
238type BuildkiteWebhook struct {
239 Event string `json:"event"`
240 Build Build `json:"build"`
241}
242
243func (s *State) handle(w http.ResponseWriter, r *http.Request) {
244 if r.URL.Path != "/" {
245 http.Error(w, "404 not found.", http.StatusNotFound)
246 return
247 }
248
249 switch r.Method {
250 case "POST":
251 if r.Header.Get("X-Buildkite-Token") != s.Token {
252 http.Error(w, "Invalid token", http.StatusBadRequest)
253 return
254 }
255
256 var data []byte
257 var err error
258 if data, err = ioutil.ReadAll(r.Body); err != nil {
259 http.Error(w, err.Error(), http.StatusBadRequest)
260 return
261 }
262
263 log.Println(string(data))
264
265 var webhook BuildkiteWebhook
266
267 if err := json.Unmarshal(data, &webhook); err != nil {
268 log.Fatalf("json decode failed: %s", err)
269 http.Error(w, err.Error(), http.StatusBadRequest)
270 return
271 }
272
273 // We've successfully received the webhook. Spawn a goroutine in case the mutex is blocked so we don't block this thread.
274 f := func() {
Austin Schuh07450432021-03-21 20:29:07 -0700275 if webhook.Event == "build.running" {
276 if webhook.Build.RebuiltFrom != nil {
277 s.mu.Lock()
278 if c, ok := s.Commits[webhook.Build.RebuiltFrom.ID]; ok {
279 log.Printf("Detected a rebuild of %s for build %s", webhook.Build.RebuiltFrom.ID, webhook.Build.ID)
280 s.Commits[webhook.Build.ID] = c
281
282 // And now remove the vote since the rebuild started.
283 cmd := exec.Command("ssh",
284 "-p",
285 "29418",
286 "-i",
287 s.Key,
288 s.User+"@software.frc971.org",
289 "gerrit",
290 "review",
291 "-m",
292 fmt.Sprintf("\"Build Started: %s\"", webhook.Build.WebURL),
293 "--verified",
294 "0",
295 fmt.Sprintf("%d,%d", c.ChangeNumber, c.Patchset))
296
297 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build Started: %s\"' --verified 0 %d,%d' and waiting for it to finish...",
298 s.Key, s.User,
299 webhook.Build.WebURL, c.ChangeNumber, c.Patchset)
300 if err := cmd.Run(); err != nil {
301 log.Printf("Command failed with error: %v", err)
302 }
303 }
304 s.mu.Unlock()
305 }
306 } else if webhook.Event == "build.finished" {
Austin Schuhfd8431e2020-12-27 19:35:04 -0800307 var commit *Commit
308 {
309 s.mu.Lock()
310 if c, ok := s.Commits[webhook.Build.ID]; ok {
311 commit = &c
Austin Schuh07450432021-03-21 20:29:07 -0700312 // While we *should* delete this now from the map, that will prevent rebuilds from being mapped correctly.
313 // Instead, leave it in the map indefinately. For the number of builds we do, it should take quite a while to use enough ram to matter. If that becomes an issue, we can either clean the list when a commit is submitted, or keep a fixed number of builds in the list and expire the oldest ones when it is time.
Austin Schuhfd8431e2020-12-27 19:35:04 -0800314 }
315 s.mu.Unlock()
316 }
317
318 if commit == nil {
319 log.Printf("Unknown commit, ID: %s", webhook.Build.ID)
320 } else {
321 var verify string
322 var status string
323
324 if webhook.Build.State == "passed" {
325 verify = "+1"
326 status = "Succeeded"
327 } else {
328 verify = "-1"
329 status = "Failed"
330 }
331
332 cmd := exec.Command("ssh",
333 "-p",
334 "29418",
335 "-i",
336 s.Key,
337 s.User+"@software.frc971.org",
338 "gerrit",
339 "review",
340 "-m",
341 fmt.Sprintf("\"Build %s: %s\"", status, webhook.Build.WebURL),
342 "--verified",
343 verify,
344 fmt.Sprintf("%d,%d", commit.ChangeNumber, commit.Patchset))
345
346 log.Printf("Running 'ssh -p 29418 -i %s %s@software.frc971.org gerrit review -m '\"Build %s: %s\"' --verified %s %d,%d' and waiting for it to finish...",
347 s.Key, s.User,
348 status, webhook.Build.WebURL, verify, commit.ChangeNumber, commit.Patchset)
349 if err := cmd.Run(); err != nil {
350 log.Printf("Command failed with error: %v", err)
351 }
352
353 }
354 if webhook.Build.State == "passed" {
355 log.Printf("Passed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
356 } else {
357 log.Printf("Failed build %s: %s", webhook.Build.ID, webhook.Build.Commit)
358 }
359 }
360 }
361
362 go f()
363
364 log.Printf("%s: %s %s %s\n", webhook.Event, webhook.Build.ID, webhook.Build.Commit, webhook.Build.Branch)
365
366 fmt.Fprintf(w, "")
367
368 default:
369 internalError := http.StatusInternalServerError
370 http.Error(w, "Invalid method", internalError)
371 log.Printf("Invalid method %s", r.Method)
372 }
373}
374
375func main() {
376 apiToken := flag.String("token", "", "API token")
377 webhookToken := flag.String("webhook_token", "", "Expected webhook token")
378 user := flag.String("user", "buildkite", "User to be in gerrit")
379 key := flag.String("key", "~/.ssh/buildkite", "SSH key to use to connect to gerrit")
380 debug := flag.Bool("debug", false, "Enable debugging")
381
382 flag.Parse()
383
384 state := State{
385 Key: *key,
386 User: *user,
387 Commits: make(map[string]Commit),
388 Token: *webhookToken,
389 }
390
391 f := func() {
392 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
393 state.handle(w, r)
394 })
395 log.Println("Starting webhook server on 10005\n")
396 if err := http.ListenAndServe(":10005", nil); err != nil {
397 log.Fatal(err)
398 }
399 }
400
401 if *apiToken == "nope" {
402 log.Println("Only starting server")
403 f()
404 } else {
405 go f()
406 }
407
408 config, err := buildkite.NewTokenConfig(*apiToken, *debug)
409
410 if err != nil {
411 log.Fatalf("client config failed: %s", err)
412 }
413
414 client := buildkite.NewClient(config.Client())
415
416 for {
417 args := fmt.Sprintf("-o ServerAliveInterval=10 -o ServerAliveCountMax=3 -i %s -p 29418 %s@software.frc971.org gerrit stream-events", state.Key, state.User)
418 cmd := exec.Command("ssh", strings.Split(args, " ")...)
419
420 stdout, _ := cmd.StdoutPipe()
421 cmd.Start()
422
423 scanner := bufio.NewScanner(stdout)
424 scanner.Split(bufio.ScanLines)
425 for scanner.Scan() {
426 m := scanner.Text()
427
428 log.Println(m)
429
430 var eventInfo EventInfo
431 dec := json.NewDecoder(bytes.NewReader([]byte(m)))
432 dec.DisallowUnknownFields()
433
434 if err := dec.Decode(&eventInfo); err != nil {
435 log.Printf("Failed to parse JSON: %e\n", err)
436 continue
437 }
438
439 log.Printf("Got an event of type: '%s'\n", eventInfo.Type)
440 switch eventInfo.Type {
441 case "assignee-changed":
442 case "change-abandoned":
443 case "change-deleted":
444 case "change-merged":
Austin Schuh68d28912021-03-21 21:59:28 -0700445 if eventInfo.RefName == "refs/heads/master" && eventInfo.Change.Status == "MERGED" {
446 if build, _, err := client.Builds.Create(
447 "spartan-robotics", "971-robot-code", &buildkite.CreateBuild{
448 Commit: eventInfo.NewRev,
449 Branch: "master",
450 Author: buildkite.Author{
451 Name: eventInfo.Submitter.Name,
452 Email: eventInfo.Submitter.Email,
453 },
454 }); err == nil {
455 log.Printf("Scheduled master build %s\n", *build.ID)
456 } else {
457 log.Printf("Failed to schedule master build %v", err)
458 // TODO(austin): Notify failure to build. Stephan should be able to pick this up in nagios.
459 }
460 }
Austin Schuhfd8431e2020-12-27 19:35:04 -0800461 case "change-restored":
462 case "comment-added":
463 if matched, _ := regexp.MatchString(`(?m)^retest$`, eventInfo.Comment); !matched {
464 continue
465 }
466
467 state.handleEvent(eventInfo, client)
468 case "dropped-output":
469 case "hashtags-changed":
470 case "project-created":
471 case "patchset-created":
472 state.handleEvent(eventInfo, client)
473 case "ref-updated":
474 case "reviewer-added":
475 case "reviewer-deleted":
476 case "topic-changed":
477 case "wip-state-changed":
478 case "private-state-changed":
479 case "vote-deleted":
480 default:
481 log.Println("Unknown case")
482 }
483 }
484 cmd.Wait()
485 }
486}