Skip to content

Commit 4ce637f

Browse files
committed
Implementing GitHub webhook handler
1 parent 0030b87 commit 4ce637f

File tree

4 files changed

+246
-33
lines changed

4 files changed

+246
-33
lines changed

libs/github/webhook/webhook.go

+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package webhook
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"net/http"
7+
8+
"github.com/google/go-github/v63/github"
9+
)
10+
11+
// EventHandlerFunc is a function that handles a webhook event.
12+
// The function should return an error if the event could not be handled.
13+
// If the error is not nil, the webhook will respond with a 500 Internal Server Error.
14+
//
15+
// It is important that this is non-blocking and does not perform any long-running operations.
16+
// GitHub will close the connection if the webhook does not respond within 10 seconds.
17+
//
18+
// Example usage:
19+
//
20+
// func(event interface{}) error {
21+
// switch event := event.(type) {
22+
// case *github.CommitCommentEvent:
23+
// processCommitCommentEvent(event)
24+
// case *github.CreateEvent:
25+
// processCreateEvent(event)
26+
// ...
27+
// }
28+
// return nil
29+
// }
30+
type EventHandlerFunc func(event interface{}) error
31+
32+
// Handler is an implementation of [http.Handler] that handles GitHub webhook events.
33+
type Handler struct {
34+
eventHandler EventHandlerFunc
35+
secretToken []byte
36+
log *slog.Logger
37+
}
38+
39+
var _ http.Handler = &Handler{}
40+
41+
type Opt func(*Handler) error
42+
43+
// WithSecretToken sets the secret token for the webhook.
44+
// The secret token is used to create a hash of the request body, which is sent in the X-Hub-Signature header.
45+
// If not set, the webhook will not verify the signature of the request.
46+
//
47+
// For more information, see: https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
48+
func WithSecretToken(secretToken []byte) Opt {
49+
return func(p *Handler) error {
50+
p.secretToken = secretToken
51+
return nil
52+
}
53+
}
54+
55+
// WithLogger sets the logger for the webhook.
56+
func WithLogger(log *slog.Logger) Opt {
57+
return func(p *Handler) error {
58+
p.log = log
59+
return nil
60+
}
61+
}
62+
63+
var defaultOpts = []Opt{
64+
WithLogger(slog.Default()),
65+
}
66+
67+
// NewHandler creates a new webhook handler.
68+
func NewHandler(eventHandler EventHandlerFunc, opts ...Opt) *Handler {
69+
h := Handler{
70+
eventHandler: eventHandler,
71+
}
72+
for _, opt := range defaultOpts {
73+
opt(&h)
74+
}
75+
76+
for _, opt := range opts {
77+
opt(&h)
78+
}
79+
return &h
80+
}
81+
82+
// Headers is a list of special headers that are sent with a webhook request.
83+
// For more information, see: https://docs.github.com/en/webhooks/webhook-events-and-payloads#delivery-headers
84+
type Headers struct {
85+
// GithubHookID is the unique identifier of the webhook.
86+
GithubHookID string
87+
// GithubEvent is the type of event that triggered the delivery.
88+
GithubEvent string
89+
// GithubDelivery is a globally unique identifier (GUID) to identify the event
90+
GithubDelivery string
91+
// GitHubHookInstallationTargetType is the type of resource where the webhook was created.
92+
GitHubHookInstallationTargetType string
93+
// GitHubHookInstallationTargetID is the unique identifier of the resource where the webhook was created.
94+
GitHubHookInstallationTargetID string
95+
96+
// HubSignature256 is the HMAC hex digest of the response body.
97+
// Is generated with the SHA-256 algorithm with a shared secret used as the HMAC key.
98+
// This header will be sent if the webhook is configured with a secret.
99+
HubSignature256 string
100+
}
101+
102+
// ServeHTTP handles a webhook request.
103+
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
104+
// Parse headers for debugging and audit purposes.
105+
var head Headers
106+
head.GithubHookID = r.Header.Get("X-GitHub-Hook-ID")
107+
head.GithubEvent = r.Header.Get("X-GitHub-Event")
108+
head.GithubDelivery = r.Header.Get("X-GitHub-Delivery")
109+
head.GitHubHookInstallationTargetType = r.Header.Get("X-GitHub-Hook-Installation-Target-Type")
110+
head.GitHubHookInstallationTargetID = r.Header.Get("X-GitHub-Hook-Installation-Target-ID")
111+
head.HubSignature256 = r.Header.Get("X-Hub-Signature-256")
112+
113+
payload, err := github.ValidatePayload(r, h.secretToken) // If secretToken is empty, the signature will not be verified.
114+
if err != nil {
115+
h.log.Warn("webhook validation failed", "headers", head)
116+
http.Error(w, "invalid request", http.StatusBadRequest)
117+
return
118+
}
119+
120+
event, err := github.ParseWebHook(head.GithubEvent, payload)
121+
if err != nil {
122+
h.log.Error("failed to parse webhook event", "error", err)
123+
http.Error(w, "invalid request", http.StatusBadRequest)
124+
return
125+
}
126+
127+
if err := h.eventHandler(event); err != nil {
128+
h.log.Error("failed to handle webhook event", "error", err)
129+
http.Error(w, "internal server error", http.StatusInternalServerError)
130+
return
131+
}
132+
133+
// Respond to the request.
134+
w.WriteHeader(http.StatusOK)
135+
}
136+
137+
// String returns a string representation of the Headers.
138+
func (h *Headers) String() string {
139+
return fmt.Sprintf("GithubHookID: %s\nGithubEvent: %s\nGithubDelivery: %s\nGitHubHookInstallationTargetType: %s\nGitHubHookInstallationTargetID: %s\nHubSignature256: %s\n",
140+
h.GithubHookID, h.GithubEvent, h.GithubDelivery, h.GitHubHookInstallationTargetType, h.GitHubHookInstallationTargetID, h.HubSignature256)
141+
}

tools/approval-service/cmd/approval-service.go

+91-33
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
package main
22

3+
import (
4+
"context"
5+
"log"
6+
"log/slog"
7+
"net/http"
8+
9+
"github.com/google/go-github/v69/github"
10+
"github.com/gravitational/shared-workflows/libs/github/webhook"
11+
"github.com/gravitational/trace"
12+
"golang.org/x/sync/errgroup"
13+
)
14+
15+
var logger = slog.Default()
16+
317
// Process:
418
// 1. Take in events from CI/CD systems
519
// 2. Extract common information
@@ -28,19 +42,22 @@ func main() {
2842
eventSources := []EventSource{
2943
NewGitHubEventSource(processor),
3044
}
31-
3245
for _, eventSource := range eventSources {
3346
_ = eventSource.Setup()
3447
}
3548

36-
done := make(chan struct{}) // TODO replace with error?
49+
// 3. Start event sources
50+
eg, ctx := errgroup.WithContext(context.Background())
3751
for _, eventSource := range eventSources {
38-
_ = eventSource.Run(done)
52+
eg.Go(func() error {
53+
return eventSource.Run(ctx)
54+
})
3955
}
4056

4157
// Block until an event source has a fatal error
42-
<-done
43-
close(done)
58+
if err := eg.Wait(); err != nil {
59+
log.Fatal(err)
60+
}
4461
}
4562

4663
// This contains information needed to process a request
@@ -99,12 +116,15 @@ type EventSource interface {
99116
Setup() error
100117

101118
// Handle actual requests. This should not block.
102-
Run(chan struct{}) error
119+
Run(ctx context.Context) error
103120
}
104121

105122
type GitHubEventSource struct {
106123
processor ApprovalProcessor
107-
// TODO
124+
125+
deployReviewChan chan *github.DeploymentReviewEvent
126+
addr string
127+
srv *http.Server
108128
}
109129

110130
func NewGitHubEventSource(processor ApprovalProcessor) *GitHubEventSource {
@@ -114,43 +134,73 @@ func NewGitHubEventSource(processor ApprovalProcessor) *GitHubEventSource {
114134
// Setup GH client, webhook secret, etc.
115135
// https://github.com/go-playground/webhooks may help here
116136
func (ghes *GitHubEventSource) Setup() error {
117-
// TODO
137+
deployReviewChan := make(chan *github.DeploymentReviewEvent)
138+
ghes.deployReviewChan = deployReviewChan
139+
140+
mux := http.NewServeMux()
141+
eventProcessor := webhook.EventHandlerFunc(func(event interface{}) error {
142+
switch event := event.(type) {
143+
case *github.DeploymentReviewEvent:
144+
deployReviewChan <- event
145+
return nil
146+
default:
147+
return trace.Errorf("unknown event type: %T", event)
148+
}
149+
})
150+
mux.Handle("/webhook", webhook.NewHandler(
151+
eventProcessor,
152+
webhook.WithSecretToken([]byte("secret-token")), // TODO: get from config
153+
webhook.WithLogger(logger),
154+
))
155+
156+
ghes.srv = &http.Server{
157+
Addr: ghes.addr,
158+
Handler: mux,
159+
}
160+
118161
return nil
119162
}
120163

121164
// Take incoming events and respond to them
122-
func (ghes *GitHubEventSource) Run(done chan struct{}) error {
123-
// If anything errors, deny the request. For safety, maybe `defer`
124-
// the "response" function?
125-
go func() {
126-
// Notify the service that the listener is completely done.
127-
// Normally this should only be hit if there is a fatal error
128-
defer func() { done <- struct{}{} }()
165+
func (ghes *GitHubEventSource) Run(ctx context.Context) error {
166+
errc := make(chan error)
129167

130-
// Incoming webhook payloads
131-
// This should be closed by the webhook listener func
132-
payloads := make(chan interface{})
133-
134-
// Listen for webhook calls
135-
go ghes.listenForPayloads(payloads, done)
168+
// Start the HTTP server
169+
go func() {
170+
logger.Info("Listening for GitHub Webhooks", "address", ghes.addr)
171+
errc <- ghes.srv.ListenAndServe()
172+
close(errc)
173+
}()
136174

137-
for payload := range payloads {
138-
go ghes.processWebhookPayload(payload, done)
175+
// Process incoming events
176+
go func() {
177+
defer close(ghes.deployReviewChan)
178+
logger.Info("Starting GitHub event processor")
179+
for {
180+
select {
181+
case <-ctx.Done():
182+
return
183+
case deployReview := <-ghes.deployReviewChan:
184+
// Process the event
185+
go ghes.processDeploymentReviewEvent(deployReview)
186+
}
139187
}
140188
}()
141189

142-
return nil
143-
}
144-
145-
// Listen for incoming webhook events. Register HTTP routes, start server, etc. Long running, blocking.
146-
func (ghes *GitHubEventSource) listenForPayloads(payloads chan interface{}, done chan struct{}) {
147-
// Once a call is received, it should return a 200 response immediately.
190+
var err error
191+
// This will block until an error occurs or the context is done
192+
select {
193+
case err = <-errc:
194+
ghes.srv.Shutdown(context.Background()) // Ignore error - we're already handling one
195+
case <-ctx.Done():
196+
err = ghes.srv.Shutdown(context.Background())
197+
<-errc // flush the error channel to avoid a goroutine leak
198+
}
148199

149-
// TODO
200+
return trace.Wrap(err)
150201
}
151202

152-
// Given an event, approve or deny it. This is a long running, blocking function.
153-
func (ghes *GitHubEventSource) processWebhookPayload(payload interface{}, done chan struct{}) {
203+
func (ghes *GitHubEventSource) processDeploymentReviewEvent(payload *github.DeploymentReviewEvent) error {
154204
// Do GitHub-specific checks. Don't approve based off ot this - just deny
155205
// if one fails.
156206
automatedDenial, err := ghes.performAutomatedChecks(payload)
@@ -170,6 +220,11 @@ func (ghes *GitHubEventSource) processWebhookPayload(payload interface{}, done c
170220
}
171221

172222
_ = ghes.respondToDeployRequest(true, payload)
223+
return nil
224+
}
225+
226+
// Given an event, approve or deny it. This is a long running, blocking function.
227+
func (ghes *GitHubEventSource) processWebhookPayload(payload interface{}, done chan struct{}) {
173228
}
174229

175230
// Turns GH-specific information into "common" information for the approver
@@ -183,10 +238,13 @@ func (ghes *GitHubEventSource) convertWebhookPayloadToEvent(payload interface{})
183238

184239
// Performs approval checks that are GH-specific. This should only be used to deny requests,
185240
// never approve them.
186-
func (ghes *GitHubEventSource) performAutomatedChecks(payload interface{}) (pass bool, err error) {
241+
func (ghes *GitHubEventSource) performAutomatedChecks(payload *github.DeploymentReviewEvent) (pass bool, err error) {
187242
// Verify request is from Gravitational org repo
188243
// Verify request is from Gravitational org member
189244
// See RFD for additional examples
245+
if *payload.Organization.Login != "gravitational" {
246+
return true, nil
247+
}
190248

191249
return false, nil
192250
}

tools/approval-service/go.mod

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
module github.com/gravitational/shared-workflows/tools/approval-service
22

33
go 1.23.5
4+
5+
require (
6+
github.com/google/go-github/v63 v63.0.0 // indirect
7+
github.com/google/go-github/v69 v69.1.0 // indirect
8+
github.com/google/go-querystring v1.1.0 // indirect
9+
)

tools/approval-service/go.sum

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
2+
github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE=
3+
github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA=
4+
github.com/google/go-github/v69 v69.1.0 h1:ljzwzEsHsc4qUqyHEJCNA1dMqvoTK3YX2NAaK6iprDg=
5+
github.com/google/go-github/v69 v69.1.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM=
6+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
7+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
8+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

0 commit comments

Comments
 (0)