github.com/argoproj/argo-cd@v1.8.7/util/webhook/webhook.go (about)

     1  package webhook
     2  
     3  import (
     4  	"context"
     5  	"net/http"
     6  	"net/url"
     7  	"path/filepath"
     8  	"regexp"
     9  	"strings"
    10  
    11  	gogsclient "github.com/gogits/go-gogs-client"
    12  	log "github.com/sirupsen/logrus"
    13  	"gopkg.in/go-playground/webhooks.v5/bitbucket"
    14  	bitbucketserver "gopkg.in/go-playground/webhooks.v5/bitbucket-server"
    15  	"gopkg.in/go-playground/webhooks.v5/github"
    16  	"gopkg.in/go-playground/webhooks.v5/gitlab"
    17  	"gopkg.in/go-playground/webhooks.v5/gogs"
    18  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    19  
    20  	"github.com/argoproj/argo-cd/common"
    21  	"github.com/argoproj/argo-cd/pkg/apis/application/v1alpha1"
    22  	appclientset "github.com/argoproj/argo-cd/pkg/client/clientset/versioned"
    23  	"github.com/argoproj/argo-cd/reposerver/cache"
    24  	"github.com/argoproj/argo-cd/util/argo"
    25  	"github.com/argoproj/argo-cd/util/security"
    26  	"github.com/argoproj/argo-cd/util/settings"
    27  )
    28  
    29  type settingsSource interface {
    30  	GetAppInstanceLabelKey() (string, error)
    31  }
    32  
    33  type ArgoCDWebhookHandler struct {
    34  	cache           *cache.Cache
    35  	ns              string
    36  	appClientset    appclientset.Interface
    37  	github          *github.Webhook
    38  	gitlab          *gitlab.Webhook
    39  	bitbucket       *bitbucket.Webhook
    40  	bitbucketserver *bitbucketserver.Webhook
    41  	gogs            *gogs.Webhook
    42  	settingsSrc     settingsSource
    43  }
    44  
    45  func NewHandler(namespace string, appClientset appclientset.Interface, set *settings.ArgoCDSettings, settingsSrc settingsSource, cache *cache.Cache) *ArgoCDWebhookHandler {
    46  	githubWebhook, err := github.New(github.Options.Secret(set.WebhookGitHubSecret))
    47  	if err != nil {
    48  		log.Warnf("Unable to init the Github webhook")
    49  	}
    50  	gitlabWebhook, err := gitlab.New(gitlab.Options.Secret(set.WebhookGitLabSecret))
    51  	if err != nil {
    52  		log.Warnf("Unable to init the Gitlab webhook")
    53  	}
    54  	bitbucketWebhook, err := bitbucket.New(bitbucket.Options.UUID(set.WebhookBitbucketUUID))
    55  	if err != nil {
    56  		log.Warnf("Unable to init the Bitbucket webhook")
    57  	}
    58  	bitbucketserverWebhook, err := bitbucketserver.New(bitbucketserver.Options.Secret(set.WebhookBitbucketServerSecret))
    59  	if err != nil {
    60  		log.Warnf("Unable to init the Bitbucket Server webhook")
    61  	}
    62  	gogsWebhook, err := gogs.New(gogs.Options.Secret(set.WebhookGogsSecret))
    63  	if err != nil {
    64  		log.Warnf("Unable to init the Gogs webhook")
    65  	}
    66  
    67  	acdWebhook := ArgoCDWebhookHandler{
    68  		ns:              namespace,
    69  		appClientset:    appClientset,
    70  		github:          githubWebhook,
    71  		gitlab:          gitlabWebhook,
    72  		bitbucket:       bitbucketWebhook,
    73  		bitbucketserver: bitbucketserverWebhook,
    74  		gogs:            gogsWebhook,
    75  		settingsSrc:     settingsSrc,
    76  		cache:           cache,
    77  	}
    78  
    79  	return &acdWebhook
    80  }
    81  
    82  // affectedRevisionInfo examines a payload from a webhook event, and extracts the repo web URL,
    83  // the revision, and whether or not this affected origin/HEAD (the default branch of the repository)
    84  func affectedRevisionInfo(payloadIf interface{}) (webURLs []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) {
    85  	parseRef := func(ref string) string {
    86  		refParts := strings.SplitN(ref, "/", 3)
    87  		return refParts[len(refParts)-1]
    88  	}
    89  
    90  	switch payload := payloadIf.(type) {
    91  	case github.PushPayload:
    92  		// See: https://developer.github.com/v3/activity/events/types/#pushevent
    93  		webURLs = append(webURLs, payload.Repository.HTMLURL)
    94  		revision = parseRef(payload.Ref)
    95  		change.shaAfter = parseRef(payload.After)
    96  		change.shaBefore = parseRef(payload.Before)
    97  		touchedHead = bool(payload.Repository.DefaultBranch == revision)
    98  		for _, commit := range payload.Commits {
    99  			changedFiles = append(changedFiles, commit.Added...)
   100  			changedFiles = append(changedFiles, commit.Modified...)
   101  			changedFiles = append(changedFiles, commit.Removed...)
   102  		}
   103  	case gitlab.PushEventPayload:
   104  		// See: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
   105  		webURLs = append(webURLs, payload.Project.WebURL)
   106  		revision = parseRef(payload.Ref)
   107  		change.shaAfter = parseRef(payload.After)
   108  		change.shaBefore = parseRef(payload.Before)
   109  		touchedHead = bool(payload.Project.DefaultBranch == revision)
   110  		for _, commit := range payload.Commits {
   111  			changedFiles = append(changedFiles, commit.Added...)
   112  			changedFiles = append(changedFiles, commit.Modified...)
   113  			changedFiles = append(changedFiles, commit.Removed...)
   114  		}
   115  	case gitlab.TagEventPayload:
   116  		// See: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
   117  		// NOTE: this is untested
   118  		webURLs = append(webURLs, payload.Project.WebURL)
   119  		revision = parseRef(payload.Ref)
   120  		change.shaAfter = parseRef(payload.After)
   121  		change.shaBefore = parseRef(payload.Before)
   122  		touchedHead = bool(payload.Project.DefaultBranch == revision)
   123  		for _, commit := range payload.Commits {
   124  			changedFiles = append(changedFiles, commit.Added...)
   125  			changedFiles = append(changedFiles, commit.Modified...)
   126  			changedFiles = append(changedFiles, commit.Removed...)
   127  		}
   128  	case bitbucket.RepoPushPayload:
   129  		// See: https://confluence.atlassian.com/bitbucket/event-payloads-740262817.html#EventPayloads-Push
   130  		// NOTE: this is untested
   131  		webURLs = append(webURLs, payload.Repository.Links.HTML.Href)
   132  		// TODO: bitbucket includes multiple changes as part of a single event.
   133  		// We only pick the first but need to consider how to handle multiple
   134  		for _, change := range payload.Push.Changes {
   135  			revision = change.New.Name
   136  			break
   137  		}
   138  		// Not actually sure how to check if the incoming change affected HEAD just by examining the
   139  		// payload alone. To be safe, we just return true and let the controller check for himself.
   140  		touchedHead = true
   141  
   142  	// Bitbucket does not include a list of changed files anywhere in it's payload
   143  	// so we cannot update changedFiles for this type of payload
   144  	case bitbucketserver.RepositoryReferenceChangedPayload:
   145  
   146  		// Webhook module does not parse the inner links
   147  		for _, l := range payload.Repository.Links["clone"].([]interface{}) {
   148  			link := l.(map[string]interface{})
   149  			if link["name"] == "http" {
   150  				webURLs = append(webURLs, link["href"].(string))
   151  			}
   152  			if link["name"] == "ssh" {
   153  				webURLs = append(webURLs, link["href"].(string))
   154  			}
   155  		}
   156  
   157  		// TODO: bitbucket includes multiple changes as part of a single event.
   158  		// We only pick the first but need to consider how to handle multiple
   159  		for _, change := range payload.Changes {
   160  			revision = parseRef(change.Reference.ID)
   161  			break
   162  		}
   163  		// Not actually sure how to check if the incoming change affected HEAD just by examining the
   164  		// payload alone. To be safe, we just return true and let the controller check for himself.
   165  		touchedHead = true
   166  
   167  		// Bitbucket does not include a list of changed files anywhere in it's payload
   168  		// so we cannot update changedFiles for this type of payload
   169  
   170  	case gogsclient.PushPayload:
   171  		webURLs = append(webURLs, payload.Repo.HTMLURL)
   172  		revision = parseRef(payload.Ref)
   173  		change.shaAfter = parseRef(payload.After)
   174  		change.shaBefore = parseRef(payload.Before)
   175  		touchedHead = bool(payload.Repo.DefaultBranch == revision)
   176  		for _, commit := range payload.Commits {
   177  			changedFiles = append(changedFiles, commit.Added...)
   178  			changedFiles = append(changedFiles, commit.Modified...)
   179  			changedFiles = append(changedFiles, commit.Removed...)
   180  		}
   181  	}
   182  	return webURLs, revision, change, touchedHead, changedFiles
   183  }
   184  
   185  type changeInfo struct {
   186  	shaBefore string
   187  	shaAfter  string
   188  }
   189  
   190  // HandleEvent handles webhook events for repo push events
   191  func (a *ArgoCDWebhookHandler) HandleEvent(payload interface{}) {
   192  	webURLs, revision, change, touchedHead, changedFiles := affectedRevisionInfo(payload)
   193  	// NOTE: the webURL does not include the .git extension
   194  	if len(webURLs) == 0 {
   195  		log.Info("Ignoring webhook event")
   196  		return
   197  	}
   198  	for _, webURL := range webURLs {
   199  		log.Infof("Received push event repo: %s, revision: %s, touchedHead: %v", webURL, revision, touchedHead)
   200  	}
   201  	appIf := a.appClientset.ArgoprojV1alpha1().Applications(a.ns)
   202  	apps, err := appIf.List(context.Background(), metav1.ListOptions{})
   203  	if err != nil {
   204  		log.Warnf("Failed to list applications: %v", err)
   205  		return
   206  	}
   207  
   208  	appInstanceLabelKey, err := a.settingsSrc.GetAppInstanceLabelKey()
   209  	if err != nil {
   210  		log.Warnf("Failed to get appInstanceLabelKey: %v", err)
   211  		return
   212  	}
   213  
   214  	for _, webURL := range webURLs {
   215  		urlObj, err := url.Parse(webURL)
   216  		if err != nil {
   217  			log.Warnf("Failed to parse repoURL '%s'", webURL)
   218  			continue
   219  		}
   220  
   221  		regexpStr := `(?i)(http://|https://|\w+@|ssh://(\w+@)?)` + urlObj.Hostname() + "(:[0-9]+|)[:/]" + urlObj.Path[1:] + "(\\.git)?"
   222  		repoRegexp, err := regexp.Compile(regexpStr)
   223  		if err != nil {
   224  			log.Warnf("Failed to compile regexp for repoURL '%s'", webURL)
   225  			continue
   226  		}
   227  
   228  		for _, app := range apps.Items {
   229  			if appRevisionHasChanged(&app, revision, touchedHead) && appUsesURL(&app, webURL, repoRegexp) {
   230  				if appFilesHaveChanged(&app, changedFiles) {
   231  					_, err = argo.RefreshApp(appIf, app.ObjectMeta.Name, v1alpha1.RefreshTypeNormal)
   232  					if err != nil {
   233  						log.Warnf("Failed to refresh app '%s' for controller reprocessing: %v", app.ObjectMeta.Name, err)
   234  						continue
   235  					}
   236  				} else if change.shaBefore != "" && change.shaAfter != "" {
   237  					var cachedManifests cache.CachedManifestResponse
   238  					if err := a.cache.GetManifests(change.shaBefore, &app.Spec.Source, app.Spec.Destination.Namespace, appInstanceLabelKey, app.Name, &cachedManifests); err == nil {
   239  						if err = a.cache.SetManifests(change.shaAfter, &app.Spec.Source, app.Spec.Destination.Namespace, appInstanceLabelKey, app.Name, &cachedManifests); err != nil {
   240  							log.Warnf("Failed to store cached manifests of previous revision for app '%s': %v", app.Name, err)
   241  						}
   242  					}
   243  				}
   244  			}
   245  		}
   246  	}
   247  }
   248  
   249  func getAppRefreshPaths(app *v1alpha1.Application) []string {
   250  	var paths []string
   251  	if val, ok := app.Annotations[common.AnnotationKeyManifestGeneratePaths]; ok && val != "" {
   252  		for _, item := range strings.Split(val, ";") {
   253  			if item == "" {
   254  				continue
   255  			}
   256  			if filepath.IsAbs(item) {
   257  				item = item[1:]
   258  			} else {
   259  				item = filepath.Clean(filepath.Join(app.Spec.Source.Path, item))
   260  			}
   261  			paths = append(paths, item)
   262  		}
   263  	}
   264  	return paths
   265  }
   266  
   267  func appFilesHaveChanged(app *v1alpha1.Application, changedFiles []string) bool {
   268  	// an empty slice of changed files means that the payload didn't include a list
   269  	// of changed files and w have to assume that a refresh is required
   270  	if len(changedFiles) == 0 {
   271  		return true
   272  	}
   273  
   274  	// Check to see if the app has requested refreshes only on a specific prefix
   275  	refreshPaths := getAppRefreshPaths(app)
   276  
   277  	if len(refreshPaths) == 0 {
   278  		// Apps without a given refreshed paths always be refreshed, regardless of changed files
   279  		// this is the "default" behavior
   280  		return true
   281  	}
   282  
   283  	// At last one changed file must be under refresh path
   284  	for _, f := range changedFiles {
   285  		f = ensureAbsPath(f)
   286  		for _, item := range refreshPaths {
   287  			item = ensureAbsPath(item)
   288  
   289  			if _, err := security.EnforceToCurrentRoot(item, f); err == nil {
   290  				log.WithField("application", app.Name).Debugf("Application uses files that have changed")
   291  				return true
   292  			}
   293  		}
   294  	}
   295  
   296  	log.WithField("application", app.Name).Debugf("Application does not use any of the files that have changed")
   297  	return false
   298  }
   299  
   300  func ensureAbsPath(input string) string {
   301  	if !filepath.IsAbs(input) {
   302  		return string(filepath.Separator) + input
   303  	}
   304  	return input
   305  }
   306  
   307  func appRevisionHasChanged(app *v1alpha1.Application, revision string, touchedHead bool) bool {
   308  	targetRev := app.Spec.Source.TargetRevision
   309  	if targetRev == "HEAD" || targetRev == "" { // revision is head
   310  		return touchedHead
   311  	}
   312  
   313  	return targetRev == revision
   314  }
   315  
   316  func appUsesURL(app *v1alpha1.Application, webURL string, repoRegexp *regexp.Regexp) bool {
   317  	if !repoRegexp.MatchString(app.Spec.Source.RepoURL) {
   318  		log.Debugf("%s does not match %s", app.Spec.Source.RepoURL, repoRegexp.String())
   319  		return false
   320  	}
   321  
   322  	log.Debugf("%s uses repoURL %s", app.Spec.Source.RepoURL, webURL)
   323  	return true
   324  }
   325  
   326  func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) {
   327  
   328  	var payload interface{}
   329  	var err error
   330  
   331  	switch {
   332  	//Gogs needs to be checked before Github since it carries both Gogs and (incompatible) Github headers
   333  	case r.Header.Get("X-Gogs-Event") != "":
   334  		payload, err = a.gogs.Parse(r, gogs.PushEvent)
   335  	case r.Header.Get("X-GitHub-Event") != "":
   336  		payload, err = a.github.Parse(r, github.PushEvent)
   337  	case r.Header.Get("X-Gitlab-Event") != "":
   338  		payload, err = a.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents)
   339  	case r.Header.Get("X-Hook-UUID") != "":
   340  		payload, err = a.bitbucket.Parse(r, bitbucket.RepoPushEvent)
   341  	case r.Header.Get("X-Event-Key") != "":
   342  		payload, err = a.bitbucketserver.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent)
   343  	default:
   344  		log.Debug("Ignoring unknown webhook event")
   345  		return
   346  	}
   347  
   348  	if err != nil {
   349  		log.Infof("Webhook processing failed: %s", err)
   350  		return
   351  	}
   352  
   353  	a.HandleEvent(payload)
   354  }