github.com/argoproj/argo-cd/v3@v3.2.1/util/webhook/webhook.go (about)

     1  package webhook
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"html"
     8  	"net/http"
     9  	"net/url"
    10  	"regexp"
    11  	"strings"
    12  	"sync"
    13  	"time"
    14  
    15  	bb "github.com/ktrysmt/go-bitbucket"
    16  	"k8s.io/apimachinery/pkg/labels"
    17  
    18  	alpha1 "github.com/argoproj/argo-cd/v3/pkg/client/listers/application/v1alpha1"
    19  
    20  	"github.com/Masterminds/semver/v3"
    21  	"github.com/go-playground/webhooks/v6/azuredevops"
    22  	"github.com/go-playground/webhooks/v6/bitbucket"
    23  	bitbucketserver "github.com/go-playground/webhooks/v6/bitbucket-server"
    24  	"github.com/go-playground/webhooks/v6/github"
    25  	"github.com/go-playground/webhooks/v6/gitlab"
    26  	"github.com/go-playground/webhooks/v6/gogs"
    27  	gogsclient "github.com/gogits/go-gogs-client"
    28  	log "github.com/sirupsen/logrus"
    29  
    30  	"github.com/argoproj/argo-cd/v3/common"
    31  	"github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1"
    32  	appclientset "github.com/argoproj/argo-cd/v3/pkg/client/clientset/versioned"
    33  	"github.com/argoproj/argo-cd/v3/reposerver/cache"
    34  	servercache "github.com/argoproj/argo-cd/v3/server/cache"
    35  	"github.com/argoproj/argo-cd/v3/util/app/path"
    36  	"github.com/argoproj/argo-cd/v3/util/argo"
    37  	"github.com/argoproj/argo-cd/v3/util/db"
    38  	"github.com/argoproj/argo-cd/v3/util/git"
    39  	"github.com/argoproj/argo-cd/v3/util/glob"
    40  	"github.com/argoproj/argo-cd/v3/util/guard"
    41  	"github.com/argoproj/argo-cd/v3/util/settings"
    42  )
    43  
    44  type settingsSource interface {
    45  	GetAppInstanceLabelKey() (string, error)
    46  	GetTrackingMethod() (string, error)
    47  	GetInstallationID() (string, error)
    48  }
    49  
    50  // https://www.rfc-editor.org/rfc/rfc3986#section-3.2.1
    51  // https://github.com/shadow-maint/shadow/blob/master/libmisc/chkname.c#L36
    52  const usernameRegex = `[\w\.][\w\.-]{0,30}[\w\.\$-]?`
    53  
    54  const payloadQueueSize = 50000
    55  
    56  const panicMsgServer = "panic while processing api-server webhook event"
    57  
    58  var _ settingsSource = &settings.SettingsManager{}
    59  
    60  type ArgoCDWebhookHandler struct {
    61  	sync.WaitGroup         // for testing
    62  	repoCache              *cache.Cache
    63  	serverCache            *servercache.Cache
    64  	db                     db.ArgoDB
    65  	ns                     string
    66  	appNs                  []string
    67  	appClientset           appclientset.Interface
    68  	appsLister             alpha1.ApplicationLister
    69  	github                 *github.Webhook
    70  	gitlab                 *gitlab.Webhook
    71  	bitbucket              *bitbucket.Webhook
    72  	bitbucketserver        *bitbucketserver.Webhook
    73  	azuredevops            *azuredevops.Webhook
    74  	gogs                   *gogs.Webhook
    75  	settings               *settings.ArgoCDSettings
    76  	settingsSrc            settingsSource
    77  	queue                  chan any
    78  	maxWebhookPayloadSizeB int64
    79  }
    80  
    81  func NewHandler(namespace string, applicationNamespaces []string, webhookParallelism int, appClientset appclientset.Interface, appsLister alpha1.ApplicationLister, set *settings.ArgoCDSettings, settingsSrc settingsSource, repoCache *cache.Cache, serverCache *servercache.Cache, argoDB db.ArgoDB, maxWebhookPayloadSizeB int64) *ArgoCDWebhookHandler {
    82  	githubWebhook, err := github.New(github.Options.Secret(set.GetWebhookGitHubSecret()))
    83  	if err != nil {
    84  		log.Warnf("Unable to init the GitHub webhook")
    85  	}
    86  	gitlabWebhook, err := gitlab.New(gitlab.Options.Secret(set.GetWebhookGitLabSecret()))
    87  	if err != nil {
    88  		log.Warnf("Unable to init the GitLab webhook")
    89  	}
    90  	bitbucketWebhook, err := bitbucket.New(bitbucket.Options.UUID(set.GetWebhookBitbucketUUID()))
    91  	if err != nil {
    92  		log.Warnf("Unable to init the Bitbucket webhook")
    93  	}
    94  	bitbucketserverWebhook, err := bitbucketserver.New(bitbucketserver.Options.Secret(set.GetWebhookBitbucketServerSecret()))
    95  	if err != nil {
    96  		log.Warnf("Unable to init the Bitbucket Server webhook")
    97  	}
    98  	gogsWebhook, err := gogs.New(gogs.Options.Secret(set.GetWebhookGogsSecret()))
    99  	if err != nil {
   100  		log.Warnf("Unable to init the Gogs webhook")
   101  	}
   102  	azuredevopsWebhook, err := azuredevops.New(azuredevops.Options.BasicAuth(set.GetWebhookAzureDevOpsUsername(), set.GetWebhookAzureDevOpsPassword()))
   103  	if err != nil {
   104  		log.Warnf("Unable to init the Azure DevOps webhook")
   105  	}
   106  
   107  	acdWebhook := ArgoCDWebhookHandler{
   108  		ns:                     namespace,
   109  		appNs:                  applicationNamespaces,
   110  		appClientset:           appClientset,
   111  		github:                 githubWebhook,
   112  		gitlab:                 gitlabWebhook,
   113  		bitbucket:              bitbucketWebhook,
   114  		bitbucketserver:        bitbucketserverWebhook,
   115  		azuredevops:            azuredevopsWebhook,
   116  		gogs:                   gogsWebhook,
   117  		settingsSrc:            settingsSrc,
   118  		repoCache:              repoCache,
   119  		serverCache:            serverCache,
   120  		settings:               set,
   121  		db:                     argoDB,
   122  		queue:                  make(chan any, payloadQueueSize),
   123  		maxWebhookPayloadSizeB: maxWebhookPayloadSizeB,
   124  		appsLister:             appsLister,
   125  	}
   126  
   127  	acdWebhook.startWorkerPool(webhookParallelism)
   128  
   129  	return &acdWebhook
   130  }
   131  
   132  func (a *ArgoCDWebhookHandler) startWorkerPool(webhookParallelism int) {
   133  	compLog := log.WithField("component", "api-server-webhook")
   134  	for i := 0; i < webhookParallelism; i++ {
   135  		a.Add(1)
   136  		go func() {
   137  			defer a.Done()
   138  			for {
   139  				payload, ok := <-a.queue
   140  				if !ok {
   141  					return
   142  				}
   143  				guard.RecoverAndLog(func() { a.HandleEvent(payload) }, compLog, panicMsgServer)
   144  			}
   145  		}()
   146  	}
   147  }
   148  
   149  func ParseRevision(ref string) string {
   150  	refParts := strings.SplitN(ref, "/", 3)
   151  	return refParts[len(refParts)-1]
   152  }
   153  
   154  // affectedRevisionInfo examines a payload from a webhook event, and extracts the repo web URL,
   155  // the revision, and whether, or not this affected origin/HEAD (the default branch of the repository)
   156  func (a *ArgoCDWebhookHandler) affectedRevisionInfo(payloadIf any) (webURLs []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) {
   157  	switch payload := payloadIf.(type) {
   158  	case azuredevops.GitPushEvent:
   159  		// See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push
   160  		webURLs = append(webURLs, payload.Resource.Repository.RemoteURL)
   161  		if len(payload.Resource.RefUpdates) > 0 {
   162  			revision = ParseRevision(payload.Resource.RefUpdates[0].Name)
   163  			change.shaAfter = ParseRevision(payload.Resource.RefUpdates[0].NewObjectID)
   164  			change.shaBefore = ParseRevision(payload.Resource.RefUpdates[0].OldObjectID)
   165  			touchedHead = payload.Resource.RefUpdates[0].Name == payload.Resource.Repository.DefaultBranch
   166  		}
   167  		// unfortunately, Azure DevOps doesn't provide a list of changed files
   168  	case github.PushPayload:
   169  		// See: https://developer.github.com/v3/activity/events/types/#pushevent
   170  		webURLs = append(webURLs, payload.Repository.HTMLURL)
   171  		revision = ParseRevision(payload.Ref)
   172  		change.shaAfter = ParseRevision(payload.After)
   173  		change.shaBefore = ParseRevision(payload.Before)
   174  		touchedHead = bool(payload.Repository.DefaultBranch == revision)
   175  		for _, commit := range payload.Commits {
   176  			changedFiles = append(changedFiles, commit.Added...)
   177  			changedFiles = append(changedFiles, commit.Modified...)
   178  			changedFiles = append(changedFiles, commit.Removed...)
   179  		}
   180  	case gitlab.PushEventPayload:
   181  		// See: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
   182  		webURLs = append(webURLs, payload.Project.WebURL)
   183  		revision = ParseRevision(payload.Ref)
   184  		change.shaAfter = ParseRevision(payload.After)
   185  		change.shaBefore = ParseRevision(payload.Before)
   186  		touchedHead = bool(payload.Project.DefaultBranch == revision)
   187  		for _, commit := range payload.Commits {
   188  			changedFiles = append(changedFiles, commit.Added...)
   189  			changedFiles = append(changedFiles, commit.Modified...)
   190  			changedFiles = append(changedFiles, commit.Removed...)
   191  		}
   192  	case gitlab.TagEventPayload:
   193  		// See: https://docs.gitlab.com/ee/user/project/integrations/webhooks.html
   194  		// NOTE: this is untested
   195  		webURLs = append(webURLs, payload.Project.WebURL)
   196  		revision = ParseRevision(payload.Ref)
   197  		change.shaAfter = ParseRevision(payload.After)
   198  		change.shaBefore = ParseRevision(payload.Before)
   199  		touchedHead = bool(payload.Project.DefaultBranch == revision)
   200  		for _, commit := range payload.Commits {
   201  			changedFiles = append(changedFiles, commit.Added...)
   202  			changedFiles = append(changedFiles, commit.Modified...)
   203  			changedFiles = append(changedFiles, commit.Removed...)
   204  		}
   205  	case bitbucket.RepoPushPayload:
   206  		// See: https://confluence.atlassian.com/bitbucket/event-payloads-740262817.html#EventPayloads-Push
   207  		// NOTE: this is untested
   208  		webURLs = append(webURLs, payload.Repository.Links.HTML.Href)
   209  		for _, changes := range payload.Push.Changes {
   210  			revision = changes.New.Name
   211  			change.shaBefore = changes.Old.Target.Hash
   212  			change.shaAfter = changes.New.Target.Hash
   213  			break
   214  		}
   215  		// Not actually sure how to check if the incoming change affected HEAD just by examining the
   216  		// payload alone. To be safe, we just return true and let the controller check for himself.
   217  		touchedHead = true
   218  
   219  		// Get DiffSet only for authenticated webhooks.
   220  		// when WebhookBitbucketUUID is set in argocd-secret, then the payload must be signed and
   221  		// signature is validated before payload is parsed.
   222  		if a.settings.GetWebhookBitbucketUUID() != "" {
   223  			ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
   224  			defer cancel()
   225  			argoRepo, err := a.lookupRepository(ctx, webURLs[0])
   226  			if err != nil {
   227  				log.Warnf("error trying to find a matching repo for URL %s: %v", payload.Repository.Links.HTML.Href, err)
   228  				break
   229  			}
   230  			if argoRepo == nil {
   231  				// it could be a public repository with no repo creds stored.
   232  				// initialize with empty bearer token to use the no auth bitbucket client.
   233  				log.Debugf("no bitbucket repository configured for URL %s, initializing with empty bearer token", webURLs[0])
   234  				argoRepo = &v1alpha1.Repository{BearerToken: "", Repo: webURLs[0]}
   235  			}
   236  			apiBaseURL := strings.ReplaceAll(payload.Repository.Links.Self.Href, "/repositories/"+payload.Repository.FullName, "")
   237  			bbClient, err := newBitbucketClient(ctx, argoRepo, apiBaseURL)
   238  			if err != nil {
   239  				log.Warnf("error creating Bitbucket client for repo %s: %v", payload.Repository.Name, err)
   240  				break
   241  			}
   242  			log.Debugf("created bitbucket client with base URL '%s'", apiBaseURL)
   243  			owner := strings.ReplaceAll(payload.Repository.FullName, "/"+payload.Repository.Name, "")
   244  			spec := change.shaBefore + ".." + change.shaAfter
   245  			diffStatChangedFiles, err := fetchDiffStatFromBitbucket(ctx, bbClient, owner, payload.Repository.Name, spec)
   246  			if err != nil {
   247  				log.Warnf("error fetching changed files using bitbucket diffstat api: %v", err)
   248  			}
   249  			changedFiles = append(changedFiles, diffStatChangedFiles...)
   250  			touchedHead, err = isHeadTouched(ctx, bbClient, owner, payload.Repository.Name, revision)
   251  			if err != nil {
   252  				log.Warnf("error fetching bitbucket repo details: %v", err)
   253  				// To be safe, we just return true and let the controller check for himself.
   254  				touchedHead = true
   255  			}
   256  		}
   257  
   258  	// Bitbucket does not include a list of changed files anywhere in it's payload
   259  	// so we cannot update changedFiles for this type of payload
   260  	case bitbucketserver.RepositoryReferenceChangedPayload:
   261  
   262  		// Webhook module does not parse the inner links
   263  		if payload.Repository.Links != nil {
   264  			clone, ok := payload.Repository.Links["clone"].([]any)
   265  			if ok {
   266  				for _, l := range clone {
   267  					link := l.(map[string]any)
   268  					if link["name"] == "http" || link["name"] == "ssh" {
   269  						if href, ok := link["href"].(string); ok {
   270  							webURLs = append(webURLs, href)
   271  						}
   272  					}
   273  				}
   274  			}
   275  		}
   276  
   277  		// TODO: bitbucket includes multiple changes as part of a single event.
   278  		// We only pick the first but need to consider how to handle multiple
   279  		for _, change := range payload.Changes {
   280  			revision = ParseRevision(change.Reference.ID)
   281  			break
   282  		}
   283  		// Not actually sure how to check if the incoming change affected HEAD just by examining the
   284  		// payload alone. To be safe, we just return true and let the controller check for himself.
   285  		touchedHead = true
   286  
   287  		// Bitbucket does not include a list of changed files anywhere in it's payload
   288  		// so we cannot update changedFiles for this type of payload
   289  
   290  	case gogsclient.PushPayload:
   291  		revision = ParseRevision(payload.Ref)
   292  		change.shaAfter = ParseRevision(payload.After)
   293  		change.shaBefore = ParseRevision(payload.Before)
   294  		if payload.Repo != nil {
   295  			webURLs = append(webURLs, payload.Repo.HTMLURL)
   296  			touchedHead = payload.Repo.DefaultBranch == revision
   297  		}
   298  		for _, commit := range payload.Commits {
   299  			changedFiles = append(changedFiles, commit.Added...)
   300  			changedFiles = append(changedFiles, commit.Modified...)
   301  			changedFiles = append(changedFiles, commit.Removed...)
   302  		}
   303  	}
   304  	return webURLs, revision, change, touchedHead, changedFiles
   305  }
   306  
   307  type changeInfo struct {
   308  	shaBefore string
   309  	shaAfter  string
   310  }
   311  
   312  // HandleEvent handles webhook events for repo push events
   313  func (a *ArgoCDWebhookHandler) HandleEvent(payload any) {
   314  	webURLs, revision, change, touchedHead, changedFiles := a.affectedRevisionInfo(payload)
   315  	// NOTE: the webURL does not include the .git extension
   316  	if len(webURLs) == 0 {
   317  		log.Info("Ignoring webhook event")
   318  		return
   319  	}
   320  	for _, webURL := range webURLs {
   321  		log.Infof("Received push event repo: %s, revision: %s, touchedHead: %v", webURL, revision, touchedHead)
   322  	}
   323  
   324  	nsFilter := a.ns
   325  	if len(a.appNs) > 0 {
   326  		// Retrieve app from all namespaces
   327  		nsFilter = ""
   328  	}
   329  
   330  	appIf := a.appsLister.Applications(nsFilter)
   331  	apps, err := appIf.List(labels.Everything())
   332  	if err != nil {
   333  		log.Warnf("Failed to list applications: %v", err)
   334  		return
   335  	}
   336  
   337  	installationID, err := a.settingsSrc.GetInstallationID()
   338  	if err != nil {
   339  		log.Warnf("Failed to get installation ID: %v", err)
   340  		return
   341  	}
   342  	trackingMethod, err := a.settingsSrc.GetTrackingMethod()
   343  	if err != nil {
   344  		log.Warnf("Failed to get trackingMethod: %v", err)
   345  		return
   346  	}
   347  	appInstanceLabelKey, err := a.settingsSrc.GetAppInstanceLabelKey()
   348  	if err != nil {
   349  		log.Warnf("Failed to get appInstanceLabelKey: %v", err)
   350  		return
   351  	}
   352  
   353  	// Skip any application that is neither in the control plane's namespace
   354  	// nor in the list of enabled namespaces.
   355  	var filteredApps []v1alpha1.Application
   356  	for _, app := range apps {
   357  		if app.Namespace == a.ns || glob.MatchStringInList(a.appNs, app.Namespace, glob.REGEXP) {
   358  			filteredApps = append(filteredApps, *app)
   359  		}
   360  	}
   361  
   362  	for _, webURL := range webURLs {
   363  		repoRegexp, err := GetWebURLRegex(webURL)
   364  		if err != nil {
   365  			log.Warnf("Failed to get repoRegexp: %s", err)
   366  			continue
   367  		}
   368  		for _, app := range filteredApps {
   369  			if app.Spec.SourceHydrator != nil {
   370  				drySource := app.Spec.SourceHydrator.GetDrySource()
   371  				if sourceRevisionHasChanged(drySource, revision, touchedHead) && sourceUsesURL(drySource, webURL, repoRegexp) {
   372  					refreshPaths := path.GetAppRefreshPaths(&app)
   373  					if path.AppFilesHaveChanged(refreshPaths, changedFiles) {
   374  						namespacedAppInterface := a.appClientset.ArgoprojV1alpha1().Applications(app.Namespace)
   375  						log.Infof("webhook trigger refresh app to hydrate '%s'", app.Name)
   376  						_, err = argo.RefreshApp(namespacedAppInterface, app.Name, v1alpha1.RefreshTypeNormal, true)
   377  						if err != nil {
   378  							log.Warnf("Failed to hydrate app '%s' for controller reprocessing: %v", app.Name, err)
   379  							continue
   380  						}
   381  					}
   382  				}
   383  			}
   384  
   385  			for _, source := range app.Spec.GetSources() {
   386  				if sourceRevisionHasChanged(source, revision, touchedHead) && sourceUsesURL(source, webURL, repoRegexp) {
   387  					refreshPaths := path.GetAppRefreshPaths(&app)
   388  					if path.AppFilesHaveChanged(refreshPaths, changedFiles) {
   389  						namespacedAppInterface := a.appClientset.ArgoprojV1alpha1().Applications(app.Namespace)
   390  						_, err = argo.RefreshApp(namespacedAppInterface, app.Name, v1alpha1.RefreshTypeNormal, true)
   391  						if err != nil {
   392  							log.Warnf("Failed to refresh app '%s' for controller reprocessing: %v", app.Name, err)
   393  							continue
   394  						}
   395  						// No need to refresh multiple times if multiple sources match.
   396  						break
   397  					} else if change.shaBefore != "" && change.shaAfter != "" {
   398  						if err := a.storePreviouslyCachedManifests(&app, change, trackingMethod, appInstanceLabelKey, installationID); err != nil {
   399  							log.Warnf("Failed to store cached manifests of previous revision for app '%s': %v", app.Name, err)
   400  						}
   401  					}
   402  				}
   403  			}
   404  		}
   405  	}
   406  }
   407  
   408  // GetWebURLRegex compiles a regex that will match any targetRevision referring to the same repo as
   409  // the given webURL. webURL is expected to be a URL from an SCM webhook payload pointing to the web
   410  // page for the repo.
   411  func GetWebURLRegex(webURL string) (*regexp.Regexp, error) {
   412  	// 1. Optional: protocol (`http`, `https`, or `ssh`) followed by `://`
   413  	// 2. Optional: username followed by `@`
   414  	// 3. Optional: `ssh` or `altssh` subdomain
   415  	// 4. Required: hostname parsed from `webURL`
   416  	// 5. Optional: `:` followed by port number
   417  	// 6. Required: `:` or `/`
   418  	// 7. Required: path parsed from `webURL`
   419  	// 8. Optional: `.git` extension
   420  	return getURLRegex(webURL, `(?i)^((https?|ssh)://)?(%[1]s@)?((alt)?ssh\.)?%[2]s(:\d+)?[:/]%[3]s(\.git)?$`)
   421  }
   422  
   423  // GetAPIURLRegex compiles a regex that will match any targetRevision referring to the same repo as
   424  // the given apiURL.
   425  func GetAPIURLRegex(apiURL string) (*regexp.Regexp, error) {
   426  	// 1. Optional: protocol (`http` or `https`) followed by `://`
   427  	// 2. Optional: username followed by `@`
   428  	// 3. Required: hostname parsed from `webURL`
   429  	// 4. Optional: `:` followed by port number
   430  	// 5. Optional: `/`
   431  	return getURLRegex(apiURL, `(?i)^(https?://)?(%[1]s@)?%[2]s(:\d+)?/?$`)
   432  }
   433  
   434  func getURLRegex(originalURL string, regexpFormat string) (*regexp.Regexp, error) {
   435  	urlObj, err := url.Parse(originalURL)
   436  	if err != nil {
   437  		return nil, fmt.Errorf("failed to parse URL '%s'", originalURL)
   438  	}
   439  
   440  	regexEscapedHostname := regexp.QuoteMeta(urlObj.Hostname())
   441  	const urlPathSeparator = "/"
   442  	regexEscapedPath := regexp.QuoteMeta(strings.TrimPrefix(urlObj.EscapedPath(), urlPathSeparator))
   443  	regexpStr := fmt.Sprintf(regexpFormat, usernameRegex, regexEscapedHostname, regexEscapedPath)
   444  	repoRegexp, err := regexp.Compile(regexpStr)
   445  	if err != nil {
   446  		return nil, fmt.Errorf("failed to compile regexp for URL '%s'", originalURL)
   447  	}
   448  
   449  	return repoRegexp, nil
   450  }
   451  
   452  func (a *ArgoCDWebhookHandler) storePreviouslyCachedManifests(app *v1alpha1.Application, change changeInfo, trackingMethod string, appInstanceLabelKey string, installationID string) error {
   453  	destCluster, err := argo.GetDestinationCluster(context.Background(), app.Spec.Destination, a.db)
   454  	if err != nil {
   455  		return fmt.Errorf("error validating destination: %w", err)
   456  	}
   457  
   458  	var clusterInfo v1alpha1.ClusterInfo
   459  	err = a.serverCache.GetClusterInfo(destCluster.Server, &clusterInfo)
   460  	if err != nil {
   461  		return fmt.Errorf("error getting cluster info: %w", err)
   462  	}
   463  
   464  	var sources v1alpha1.ApplicationSources
   465  	if app.Spec.HasMultipleSources() {
   466  		sources = app.Spec.GetSources()
   467  	} else {
   468  		sources = append(sources, app.Spec.GetSource())
   469  	}
   470  
   471  	refSources, err := argo.GetRefSources(context.Background(), sources, app.Spec.Project, a.db.GetRepository, []string{})
   472  	if err != nil {
   473  		return fmt.Errorf("error getting ref sources: %w", err)
   474  	}
   475  	source := app.Spec.GetSource()
   476  	cache.LogDebugManifestCacheKeyFields("moving manifests cache", "webhook app revision changed", change.shaBefore, &source, refSources, &clusterInfo, app.Spec.Destination.Namespace, trackingMethod, appInstanceLabelKey, app.Name, nil)
   477  
   478  	if err := a.repoCache.SetNewRevisionManifests(change.shaAfter, change.shaBefore, &source, refSources, &clusterInfo, app.Spec.Destination.Namespace, trackingMethod, appInstanceLabelKey, app.Name, nil, installationID); err != nil {
   479  		return fmt.Errorf("error setting new revision manifests: %w", err)
   480  	}
   481  
   482  	return nil
   483  }
   484  
   485  // lookupRepository returns a repository with its credentials for a given URL. If there are no matching repository secret found,
   486  // then nil repository is returned.
   487  func (a *ArgoCDWebhookHandler) lookupRepository(ctx context.Context, repoURL string) (*v1alpha1.Repository, error) {
   488  	repositories, err := a.db.ListRepositories(ctx)
   489  	if err != nil {
   490  		return nil, fmt.Errorf("error listing repositories: %w", err)
   491  	}
   492  	var repository *v1alpha1.Repository
   493  	for _, repo := range repositories {
   494  		if git.SameURL(repo.Repo, repoURL) {
   495  			log.Debugf("found a matching repository for URL %s", repoURL)
   496  			return repo, nil
   497  		}
   498  	}
   499  	return repository, nil
   500  }
   501  
   502  func sourceRevisionHasChanged(source v1alpha1.ApplicationSource, revision string, touchedHead bool) bool {
   503  	targetRev := ParseRevision(source.TargetRevision)
   504  	if targetRev == "HEAD" || targetRev == "" { // revision is head
   505  		return touchedHead
   506  	}
   507  	targetRevisionHasPrefixList := []string{"refs/heads/", "refs/tags/"}
   508  	for _, prefix := range targetRevisionHasPrefixList {
   509  		if strings.HasPrefix(source.TargetRevision, prefix) {
   510  			return compareRevisions(revision, targetRev)
   511  		}
   512  	}
   513  
   514  	return compareRevisions(revision, source.TargetRevision)
   515  }
   516  
   517  func compareRevisions(revision string, targetRevision string) bool {
   518  	if revision == targetRevision {
   519  		return true
   520  	}
   521  
   522  	// If basic equality checking fails, it might be that the target revision is
   523  	// a semver version constraint
   524  	constraint, err := semver.NewConstraint(targetRevision)
   525  	if err != nil {
   526  		// The target revision is not a constraint
   527  		return false
   528  	}
   529  
   530  	version, err := semver.NewVersion(revision)
   531  	if err != nil {
   532  		// The new revision is not a valid semver version, so it can't match the constraint.
   533  		return false
   534  	}
   535  
   536  	return constraint.Check(version)
   537  }
   538  
   539  func sourceUsesURL(source v1alpha1.ApplicationSource, webURL string, repoRegexp *regexp.Regexp) bool {
   540  	if !repoRegexp.MatchString(source.RepoURL) {
   541  		log.Debugf("%s does not match %s", source.RepoURL, repoRegexp.String())
   542  		return false
   543  	}
   544  
   545  	log.Debugf("%s uses repoURL %s", source.RepoURL, webURL)
   546  	return true
   547  }
   548  
   549  // newBitbucketClient creates a new bitbucket client for the given repository and uses the provided apiURL to connect
   550  // to the bitbucket server. If the repository uses basic auth, then a basic auth client is created or if bearer token
   551  // is provided, then oauth based client is created.
   552  func newBitbucketClient(_ context.Context, repository *v1alpha1.Repository, apiBaseURL string) (*bb.Client, error) {
   553  	var bbClient *bb.Client
   554  	if repository.Username != "" && repository.Password != "" {
   555  		log.Debugf("fetched user/password for repository URL '%s', initializing basic auth client", repository.Repo)
   556  		if repository.Username == "x-token-auth" {
   557  			bbClient = bb.NewOAuthbearerToken(repository.Password)
   558  		} else {
   559  			bbClient = bb.NewBasicAuth(repository.Username, repository.Password)
   560  		}
   561  	} else {
   562  		if repository.BearerToken != "" {
   563  			log.Debugf("fetched bearer token for repository URL '%s', initializing bearer token auth based client", repository.Repo)
   564  		} else {
   565  			log.Debugf("no credentials available for repository URL '%s', initializing no auth client", repository.Repo)
   566  		}
   567  		bbClient = bb.NewOAuthbearerToken(repository.BearerToken)
   568  	}
   569  	// parse and set the target URL of the Bitbucket server in the client
   570  	repoBaseURL, err := url.Parse(apiBaseURL)
   571  	if err != nil {
   572  		return nil, fmt.Errorf("failed to parse bitbucket api base URL '%s'", apiBaseURL)
   573  	}
   574  	bbClient.SetApiBaseURL(*repoBaseURL)
   575  	return bbClient, nil
   576  }
   577  
   578  // fetchDiffStatFromBitbucket gets the list of files changed between two commits, by making a diffstat api callback to the
   579  // bitbucket server from where the webhook orignated.
   580  func fetchDiffStatFromBitbucket(_ context.Context, bbClient *bb.Client, owner, repoSlug, spec string) ([]string, error) {
   581  	// Getting the files changed from diff API:
   582  	// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-diffstat-spec-get
   583  
   584  	// invoke the diffstat api call to get the list of changed files between two commit shas
   585  	log.Debugf("invoking diffstat call with parameters: [Owner:%s, RepoSlug:%s, Spec:%s]", owner, repoSlug, spec)
   586  	diffStatResp, err := bbClient.Repositories.Diff.GetDiffStat(&bb.DiffStatOptions{
   587  		Owner:    owner,
   588  		RepoSlug: repoSlug,
   589  		Spec:     spec,
   590  		Renames:  true,
   591  	})
   592  	if err != nil {
   593  		return nil, fmt.Errorf("error getting the diffstat: %w", err)
   594  	}
   595  	changedFiles := make([]string, len(diffStatResp.DiffStats))
   596  	for i, value := range diffStatResp.DiffStats {
   597  		changedFilePath := value.New["path"]
   598  		if changedFilePath != nil {
   599  			changedFiles[i] = changedFilePath.(string)
   600  		}
   601  	}
   602  	log.Debugf("changed files for spec %s: %v", spec, changedFiles)
   603  	return changedFiles, nil
   604  }
   605  
   606  // isHeadTouched returns true if the repository's main branch is modified, false otherwise
   607  func isHeadTouched(ctx context.Context, bbClient *bb.Client, owner, repoSlug, revision string) (bool, error) {
   608  	bbRepoOptions := &bb.RepositoryOptions{
   609  		Owner:    owner,
   610  		RepoSlug: repoSlug,
   611  	}
   612  	bbRepo, err := bbClient.Repositories.Repository.Get(bbRepoOptions.WithContext(ctx))
   613  	if err != nil {
   614  		return false, err
   615  	}
   616  	return bbRepo.Mainbranch.Name == revision, nil
   617  }
   618  
   619  func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) {
   620  	var payload any
   621  	var err error
   622  
   623  	r.Body = http.MaxBytesReader(w, r.Body, a.maxWebhookPayloadSizeB)
   624  
   625  	switch {
   626  	case r.Header.Get("X-Vss-Activityid") != "":
   627  		payload, err = a.azuredevops.Parse(r, azuredevops.GitPushEventType)
   628  		if errors.Is(err, azuredevops.ErrBasicAuthVerificationFailed) {
   629  			log.WithField(common.SecurityField, common.SecurityHigh).Infof("Azure DevOps webhook basic auth verification failed")
   630  		}
   631  	// Gogs needs to be checked before GitHub since it carries both Gogs and (incompatible) GitHub headers
   632  	case r.Header.Get("X-Gogs-Event") != "":
   633  		payload, err = a.gogs.Parse(r, gogs.PushEvent)
   634  		if errors.Is(err, gogs.ErrHMACVerificationFailed) {
   635  			log.WithField(common.SecurityField, common.SecurityHigh).Infof("Gogs webhook HMAC verification failed")
   636  		}
   637  	case r.Header.Get("X-GitHub-Event") != "":
   638  		payload, err = a.github.Parse(r, github.PushEvent, github.PingEvent)
   639  		if errors.Is(err, github.ErrHMACVerificationFailed) {
   640  			log.WithField(common.SecurityField, common.SecurityHigh).Infof("GitHub webhook HMAC verification failed")
   641  		}
   642  	case r.Header.Get("X-Gitlab-Event") != "":
   643  		payload, err = a.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents, gitlab.SystemHookEvents)
   644  		if errors.Is(err, gitlab.ErrGitLabTokenVerificationFailed) {
   645  			log.WithField(common.SecurityField, common.SecurityHigh).Infof("GitLab webhook token verification failed")
   646  		}
   647  	case r.Header.Get("X-Hook-UUID") != "":
   648  		payload, err = a.bitbucket.Parse(r, bitbucket.RepoPushEvent)
   649  		if errors.Is(err, bitbucket.ErrUUIDVerificationFailed) {
   650  			log.WithField(common.SecurityField, common.SecurityHigh).Infof("BitBucket webhook UUID verification failed")
   651  		}
   652  	case r.Header.Get("X-Event-Key") != "":
   653  		payload, err = a.bitbucketserver.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent, bitbucketserver.DiagnosticsPingEvent)
   654  		if errors.Is(err, bitbucketserver.ErrHMACVerificationFailed) {
   655  			log.WithField(common.SecurityField, common.SecurityHigh).Infof("BitBucket webhook HMAC verification failed")
   656  		}
   657  	default:
   658  		log.Debug("Ignoring unknown webhook event")
   659  		http.Error(w, "Unknown webhook event", http.StatusBadRequest)
   660  		return
   661  	}
   662  
   663  	if err != nil {
   664  		// If the error is due to a large payload, return a more user-friendly error message
   665  		if err.Error() == "error parsing payload" {
   666  			msg := fmt.Sprintf("Webhook processing failed: The payload is either too large or corrupted. Please check the payload size (must be under %v MB) and ensure it is valid JSON", a.maxWebhookPayloadSizeB/1024/1024)
   667  			log.WithField(common.SecurityField, common.SecurityHigh).Warn(msg)
   668  			http.Error(w, msg, http.StatusBadRequest)
   669  			return
   670  		}
   671  
   672  		log.Infof("Webhook processing failed: %s", err)
   673  		status := http.StatusBadRequest
   674  		if r.Method != http.MethodPost {
   675  			status = http.StatusMethodNotAllowed
   676  		}
   677  		http.Error(w, "Webhook processing failed: "+html.EscapeString(err.Error()), status)
   678  		return
   679  	}
   680  
   681  	select {
   682  	case a.queue <- payload:
   683  	default:
   684  		log.Info("Queue is full, discarding webhook payload")
   685  		http.Error(w, "Queue is full, discarding webhook payload", http.StatusServiceUnavailable)
   686  	}
   687  }