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  }