github.com/argoproj/argo-events@v1.9.1/eventsources/sources/github/start.go (about) 1 /* 2 3 Licensed under the Apache License, Version 2.0 (the "License"); 4 you may not use this file except in compliance with the License. 5 You may obtain a copy of the License at 6 7 http://www.apache.org/licenses/LICENSE-2.0 8 9 Unless required by applicable law or agreed to in writing, software 10 distributed under the License is distributed on an "AS IS" BASIS, 11 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 See the License for the specific language governing permissions and 13 limitations under the License. 14 */ 15 16 package github 17 18 import ( 19 "context" 20 "crypto/rand" 21 "encoding/json" 22 "fmt" 23 "math/big" 24 "net/http" 25 "net/url" 26 "strings" 27 "time" 28 29 gh "github.com/google/go-github/v50/github" 30 "go.uber.org/zap" 31 corev1 "k8s.io/api/core/v1" 32 33 "github.com/argoproj/argo-events/common" 34 "github.com/argoproj/argo-events/common/logging" 35 eventsourcecommon "github.com/argoproj/argo-events/eventsources/common" 36 "github.com/argoproj/argo-events/eventsources/common/webhook" 37 "github.com/argoproj/argo-events/pkg/apis/events" 38 ) 39 40 // GitHub headers 41 const ( 42 githubEventHeader = "X-GitHub-Event" 43 githubDeliveryHeader = "X-GitHub-Delivery" 44 ) 45 46 // controller controls the webhook operations 47 var ( 48 controller = webhook.NewController() 49 ) 50 51 // set up the activation and inactivation channels to control the state of routes. 52 func init() { 53 go webhook.ProcessRouteStatus(controller) 54 } 55 56 // getCredentials retrieves credentials for GitHub connection 57 func (router *Router) getCredentials(keySelector *corev1.SecretKeySelector) (*cred, error) { 58 token, err := common.GetSecretFromVolume(keySelector) 59 if err != nil { 60 return nil, fmt.Errorf("secret not found, %w", err) 61 } 62 63 return &cred{ 64 secret: token, 65 }, nil 66 } 67 68 // getAPITokenAuthStrategy return an TokenAuthStrategy initialised with 69 // the GitHub API token provided by the user 70 func (router *Router) getAPITokenAuthStrategy() (*TokenAuthStrategy, error) { 71 apiTokenCreds, err := router.getCredentials(router.githubEventSource.APIToken) 72 if err != nil { 73 return nil, fmt.Errorf("failed to retrieve api token credentials, %w", err) 74 } 75 76 return &TokenAuthStrategy{ 77 Token: apiTokenCreds.secret, 78 }, nil 79 } 80 81 // getGithubAppAuthStrategy return an AppsAuthStrategy initialised with 82 // the GitHub App credentials provided by the user 83 func (router *Router) getGithubAppAuthStrategy() (*AppsAuthStrategy, error) { 84 appCreds := router.githubEventSource.GithubApp 85 githubAppPrivateKey, err := router.getCredentials(appCreds.PrivateKey) 86 if err != nil { 87 return nil, fmt.Errorf("failed to retrieve github app credentials, %w", err) 88 } 89 90 return &AppsAuthStrategy{ 91 AppID: appCreds.AppID, 92 BaseURL: router.githubEventSource.GithubBaseURL, 93 InstallationID: appCreds.InstallationID, 94 PrivateKey: githubAppPrivateKey.secret, 95 }, nil 96 } 97 98 // chooseAuthStrategy returns an AuthStrategy based on the given credentials 99 func (router *Router) chooseAuthStrategy() (AuthStrategy, error) { 100 es := router.githubEventSource 101 switch { 102 case es.HasGithubAPIToken(): 103 return router.getAPITokenAuthStrategy() 104 case es.HasGithubAppCreds(): 105 return router.getGithubAppAuthStrategy() 106 default: 107 return nil, fmt.Errorf("none of the supported auth options were provided") 108 } 109 } 110 111 // Implement Router 112 // 1. GetRoute 113 // 2. HandleRoute 114 // 3. PostActivate 115 // 4. PostDeactivate 116 117 // GetRoute returns the route 118 func (router *Router) GetRoute() *webhook.Route { 119 return router.route 120 } 121 122 // HandleRoute handles incoming requests on the route 123 func (router *Router) HandleRoute(writer http.ResponseWriter, request *http.Request) { 124 route := router.route 125 126 logger := route.Logger.With( 127 logging.LabelEndpoint, route.Context.Endpoint, 128 logging.LabelPort, route.Context.Port, 129 logging.LabelHTTPMethod, route.Context.Method, 130 ) 131 132 logger.Info("received a request, processing it...") 133 134 if !route.Active { 135 logger.Info("endpoint is not active, won't process the request") 136 common.SendErrorResponse(writer, "endpoint is inactive") 137 return 138 } 139 140 defer func(start time.Time) { 141 route.Metrics.EventProcessingDuration(route.EventSourceName, route.EventName, float64(time.Since(start)/time.Millisecond)) 142 }(time.Now()) 143 144 request.Body = http.MaxBytesReader(writer, request.Body, route.Context.GetMaxPayloadSize()) 145 body, err := parseValidateRequest(request, []byte(router.hookSecret)) 146 if err != nil { 147 logger.Errorw("request is not valid event notification, discarding it", zap.Error(err)) 148 common.SendErrorResponse(writer, err.Error()) 149 return 150 } 151 152 event := &events.GithubEventData{ 153 Headers: request.Header, 154 Body: (*json.RawMessage)(&body), 155 Metadata: router.githubEventSource.Metadata, 156 } 157 158 eventBody, err := json.Marshal(event) 159 if err != nil { 160 logger.Info("failed to marshal event") 161 common.SendErrorResponse(writer, "invalid event") 162 route.Metrics.EventProcessingFailed(route.EventSourceName, route.EventName) 163 return 164 } 165 166 logger.Info("dispatching event on route's data channel") 167 route.DataCh <- eventBody 168 logger.Info("request successfully processed") 169 170 common.SendSuccessResponse(writer, "success") 171 } 172 173 // PostActivate performs operations once the route is activated and ready to consume requests 174 func (router *Router) PostActivate() error { 175 return nil 176 } 177 178 // PostInactivate performs operations after the route is inactivated 179 func (router *Router) PostInactivate() error { 180 githubEventSource := router.githubEventSource 181 182 if githubEventSource.NeedToCreateHooks() && githubEventSource.DeleteHookOnFinish { 183 logger := router.route.Logger 184 logger.Info("deleting GitHub org hooks...") 185 186 for _, org := range githubEventSource.Organizations { 187 id, ok := router.orgHookIDs[org] 188 if !ok { 189 return fmt.Errorf("can not find hook ID for organization %s", org) 190 } 191 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 192 defer cancel() 193 if _, err := router.githubClient.Organizations.DeleteHook(ctx, org, id); err != nil { 194 return fmt.Errorf("failed to delete hook for organization %s. err: %w", org, err) 195 } 196 logger.Infof("GitHub hook deleted for organization %s", org) 197 } 198 199 logger.Info("deleting GitHub repo hooks...") 200 201 for _, r := range githubEventSource.GetOwnedRepositories() { 202 for _, n := range r.Names { 203 id, ok := router.repoHookIDs[r.Owner+","+n] 204 if !ok { 205 return fmt.Errorf("can not find hook ID for repo %s/%s", r.Owner, n) 206 } 207 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 208 defer cancel() 209 if _, err := router.githubClient.Repositories.DeleteHook(ctx, r.Owner, n, id); err != nil { 210 return fmt.Errorf("failed to delete hook for repo %s/%s. err: %w", r.Owner, n, err) 211 } 212 logger.Infof("GitHub hook deleted for repo %s/%s", r.Owner, n) 213 } 214 } 215 } 216 return nil 217 } 218 219 // StartListening starts an event source 220 func (el *EventListener) StartListening(ctx context.Context, dispatch func([]byte, ...eventsourcecommon.Option) error) error { 221 logger := logging.FromContext(ctx). 222 With(logging.LabelEventSourceType, el.GetEventSourceType(), logging.LabelEventName, el.GetEventName()) 223 logger.Info("started processing the Github event source...") 224 225 githubEventSource := &el.GithubEventSource 226 route := webhook.NewRoute(githubEventSource.Webhook, logger, el.GetEventSourceName(), el.GetEventName(), el.Metrics) 227 router := &Router{ 228 route: route, 229 githubEventSource: githubEventSource, 230 } 231 logger.Info("retrieving webhook secret credentials if any ...") 232 if githubEventSource.WebhookSecret != nil { 233 webhookSecretCreds, err := router.getCredentials(githubEventSource.WebhookSecret) 234 if err != nil { 235 return fmt.Errorf("failed to retrieve webhook secret. err: %w", err) 236 } 237 router.hookSecret = webhookSecretCreds.secret 238 } 239 240 if githubEventSource.NeedToCreateHooks() { 241 // create webhooks 242 243 // In order to successfully setup a GitHub hook for the given repository, 244 // 1. Parse and validate base and upload url if provided 245 // 2. Get the GitHub auth credentials and Webhook secret from K8s secrets 246 // 3. Configure the hook with url, content type, ssl etc. 247 // 4. Set up a GitHub client 248 // 5. Set the base and upload url for the client 249 // 6. Create the hook if one doesn't exist already. If exists already, then use that one. 250 251 baseURL, err := parseUrlWithSlash(&githubEventSource.GithubBaseURL) 252 if err != nil { 253 return fmt.Errorf("failed to parse github base url. err: %v", err) 254 } 255 uploadURL, err := parseUrlWithSlash(&githubEventSource.GithubUploadURL) 256 if err != nil { 257 return fmt.Errorf("failed to parse github upload url. err: %v", err) 258 } 259 260 logger.Info("choosing github auth strategy...") 261 authStrategy, err := router.chooseAuthStrategy() 262 if err != nil { 263 return fmt.Errorf("failed to get github auth strategy, %w", err) 264 } 265 266 logger.Info("setting up auth transport for http client with the chosen strategy...") 267 authTransport, err := authStrategy.AuthTransport() 268 if err != nil { 269 return fmt.Errorf("failed to set up auth transport for http client, %w", err) 270 } 271 272 logger.Info("configuring GitHub hook...") 273 formattedURL := common.FormattedURL(githubEventSource.Webhook.URL, githubEventSource.Webhook.Endpoint) 274 hookConfig := map[string]interface{}{ 275 "url": &formattedURL, 276 } 277 if githubEventSource.ContentType != "" { 278 hookConfig["content_type"] = githubEventSource.ContentType 279 } 280 if githubEventSource.Insecure { 281 hookConfig["insecure_ssl"] = "1" 282 } else { 283 hookConfig["insecure_ssl"] = "0" 284 } 285 if router.hookSecret != "" { 286 hookConfig["secret"] = router.hookSecret 287 } 288 289 logger.Info("setting up client for GitHub...") 290 client := gh.NewClient(&http.Client{Transport: authTransport}) 291 if baseURL != nil && uploadURL != nil { 292 logger.Info("setting up client for GitHub Enterprise...") 293 client.BaseURL = baseURL 294 client.UploadURL = uploadURL 295 } 296 logger.Infof("client set for baseURL=[%s] uploadURL=[%s]", client.BaseURL, client.UploadURL) 297 298 router.githubClient = client 299 router.repoHookIDs = make(map[string]int64) 300 router.orgHookIDs = make(map[string]int64) 301 302 hook := &gh.Hook{ 303 Events: githubEventSource.Events, 304 Active: gh.Bool(githubEventSource.Active), 305 Config: hookConfig, 306 } 307 308 ctx, cancel := context.WithCancel(ctx) 309 defer cancel() 310 311 f := func() { 312 for _, org := range githubEventSource.Organizations { 313 hooks, _, err := router.githubClient.Organizations.ListHooks(ctx, org, nil) 314 if err != nil { 315 logger.Errorf("failed to list existing webhooks of organization %s. err: %+v", org, err) 316 continue 317 } 318 h := getHook(hooks, formattedURL, githubEventSource.Events) 319 if h != nil { 320 router.orgHookIDs[org] = *h.ID 321 continue 322 } 323 logger.Infof("hook not found for organization %s, creating ...", org) 324 h, _, err = router.githubClient.Organizations.CreateHook(ctx, org, hook) 325 if err != nil { 326 logger.Errorf("failed to create github webhook for organization %s. err: %+v", org, err) 327 continue 328 } 329 router.orgHookIDs[org] = *h.ID 330 time.Sleep(500 * time.Millisecond) 331 } 332 333 for _, r := range githubEventSource.GetOwnedRepositories() { 334 for _, name := range r.Names { 335 hooks, _, err := router.githubClient.Repositories.ListHooks(ctx, r.Owner, name, nil) 336 if err != nil { 337 logger.Errorf("failed to list existing webhooks of %s/%s. err: %+v", r.Owner, name, err) 338 continue 339 } 340 h := getHook(hooks, formattedURL, githubEventSource.Events) 341 if h != nil { 342 router.repoHookIDs[r.Owner+","+name] = *h.ID 343 continue 344 } 345 logger.Infof("hook not found for %s/%s, creating ...", r.Owner, name) 346 h, _, err = router.githubClient.Repositories.CreateHook(ctx, r.Owner, name, hook) 347 if err != nil { 348 logger.Errorf("failed to create github webhook for %s/%s. err: %+v", r.Owner, name, err) 349 continue 350 } 351 router.repoHookIDs[r.Owner+","+name] = *h.ID 352 time.Sleep(500 * time.Millisecond) 353 } 354 } 355 } 356 357 // Github can not handle race conditions well - it might create multiple hooks with same config 358 // when replicas > 1 359 // Randomly sleep some time to mitigate the issue. 360 randomNum, _ := rand.Int(rand.Reader, big.NewInt(int64(2000))) 361 time.Sleep(time.Duration(randomNum.Int64()) * time.Millisecond) 362 f() 363 364 go func() { 365 // Another kind of race conditions might happen when pods do rolling upgrade - new pod starts 366 // and old pod terminates, if DeleteHookOnFinish is true, the hook will be deleted from github. 367 // This is a workaround to mitigate the race conditions. 368 logger.Info("starting github hooks manager daemon") 369 for i := 0; i < 10; i++ { 370 time.Sleep(60 * time.Second) 371 f() 372 } 373 logger.Info("exiting github hooks manager daemon") 374 }() 375 } else { 376 logger.Info("no need to create webhooks") 377 } 378 379 return webhook.ManageRoute(ctx, router, controller, dispatch) 380 } 381 382 // parseValidateRequest parses a http request and checks if it is valid GitHub notification 383 func parseValidateRequest(r *http.Request, secret []byte) ([]byte, error) { 384 body, err := gh.ValidatePayload(r, secret) 385 if err != nil { 386 return nil, err 387 } 388 389 payload := make(map[string]interface{}) 390 if err := json.Unmarshal(body, &payload); err != nil { 391 return nil, err 392 } 393 for _, h := range []string{ 394 githubEventHeader, 395 githubDeliveryHeader, 396 } { 397 payload[h] = r.Header.Get(h) 398 } 399 return json.Marshal(payload) 400 } 401 402 // parseUrlWithSlash parses URL and enforces trailing slash expected by GitHub client 403 func parseUrlWithSlash(urlStr *string) (*url.URL, error) { 404 if *urlStr == "" { 405 return nil, nil 406 } 407 if !strings.HasSuffix(*urlStr, "/") { 408 *urlStr += "/" 409 } 410 return url.Parse(*urlStr) 411 }