Skip to content

Commit 2fbace3

Browse files
authored
feat(webhook): Fixed manifest-generate-paths annotation support for monorepos in BitBucket (#21811)
Signed-off-by: anandf <[email protected]>
1 parent a1f90b5 commit 2fbace3

File tree

5 files changed

+569
-17
lines changed

5 files changed

+569
-17
lines changed

docs/operator-manual/webhook.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,17 @@ Syntax: `$<k8s_secret_name>:<a_key_in_that_k8s_secret>`
109109
> NOTE: Secret must have label `app.kubernetes.io/part-of: argocd`
110110

111111
For more information refer to the corresponding section in the [User Management Documentation](user-management/index.md#alternative).
112+
113+
## Special handling for BitBucket Cloud
114+
BitBucket does not include the list of changed files in the webhook request body.
115+
This prevents the [Manifest Paths Annotation](high_availability.md#manifest-paths-annotation) feature from working with repositories hosted on BitBucket Cloud.
116+
BitBucket provides the `diffstat` API to determine the list of changed files between two commits.
117+
To address the missing changed files list in the webhook, the Argo CD webhook handler makes an API callback to the originating server.
118+
To prevent Server-side request forgery (SSRF) attacks, Argo CD server supports the callback mechanism only for encrypted webhook requests.
119+
The incoming webhook must include `X-Hook-UUID` request header. The corresponding UUID must be provided as `webhook.bitbucket.uuid` in `argocd-secret` for verification.
120+
The callback mechanism supports both public and private repositories on BitBucket Cloud.
121+
For public repositories, the Argo CD webhook handler uses a no-auth client for the API callback.
122+
For private repositories, the Argo CD webhook handler searches for a valid repository OAuth token for the HTTP/HTTPS URL.
123+
The webhook handler uses this OAuth token to make the API request to the originating server.
124+
If the Argo CD webhook handler cannot find a matching repository credential, the list of changed files would remain empty.
125+
If errors occur during the callback, the list of changed files will be empty.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ require (
5959
github.com/hashicorp/go-retryablehttp v0.7.7
6060
github.com/improbable-eng/grpc-web v0.15.1-0.20230209220825-1d9bbb09a099
6161
github.com/itchyny/gojq v0.12.17
62+
github.com/jarcoal/httpmock v1.3.1
6263
github.com/jeremywohl/flatten v1.0.2-0.20211013061545-07e4a09fb8e4
6364
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
6465
github.com/ktrysmt/go-bitbucket v0.9.81

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,8 @@ github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
499499
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
500500
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
501501
github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg=
502+
github.com/jarcoal/httpmock v1.3.1 h1:iUx3whfZWVf3jT01hQTO/Eo5sAYtB2/rqaUuOtpInww=
503+
github.com/jarcoal/httpmock v1.3.1/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
502504
github.com/jaytaylor/html2text v0.0.0-20190408195923-01ec452cbe43/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
503505
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
504506
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
@@ -583,6 +585,8 @@ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
583585
github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A=
584586
github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY=
585587
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
588+
github.com/maxatome/go-testdeep v1.12.0 h1:Ql7Go8Tg0C1D/uMMX59LAoYK7LffeJQ6X2T04nTH68g=
589+
github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM=
586590
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.1-0.20241014080628-3045bdf43455 h1:7rDE4oHmFDgf+4fqnT5vztz7Bmcos1tr17VisCXgs/o=
587591
github.com/microsoft/azure-devops-go-api/azuredevops/v7 v7.1.1-0.20241014080628-3045bdf43455/go.mod h1:mDunUZ1IUJdJIRHvFb+LPBUtxe3AYB5MI6BMXNg8194=
588592
github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g=

util/webhook/webhook.go

Lines changed: 139 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
"regexp"
1111
"strings"
1212
"sync"
13+
"time"
14+
15+
bb "github.com/ktrysmt/go-bitbucket"
1316

1417
"github.com/go-playground/webhooks/v6/azuredevops"
1518
"github.com/go-playground/webhooks/v6/bitbucket"
@@ -29,6 +32,7 @@ import (
2932
"github.com/argoproj/argo-cd/v3/util/app/path"
3033
"github.com/argoproj/argo-cd/v3/util/argo"
3134
"github.com/argoproj/argo-cd/v3/util/db"
35+
"github.com/argoproj/argo-cd/v3/util/git"
3236
"github.com/argoproj/argo-cd/v3/util/glob"
3337
"github.com/argoproj/argo-cd/v3/util/settings"
3438
)
@@ -61,6 +65,7 @@ type ArgoCDWebhookHandler struct {
6165
bitbucketserver *bitbucketserver.Webhook
6266
azuredevops *azuredevops.Webhook
6367
gogs *gogs.Webhook
68+
settings *settings.ArgoCDSettings
6469
settingsSrc settingsSource
6570
queue chan any
6671
maxWebhookPayloadSizeB int64
@@ -105,6 +110,7 @@ func NewHandler(namespace string, applicationNamespaces []string, webhookParalle
105110
settingsSrc: settingsSrc,
106111
repoCache: repoCache,
107112
serverCache: serverCache,
113+
settings: set,
108114
db: argoDB,
109115
queue: make(chan any, payloadQueueSize),
110116
maxWebhookPayloadSizeB: maxWebhookPayloadSizeB,
@@ -137,8 +143,8 @@ func ParseRevision(ref string) string {
137143
}
138144

139145
// affectedRevisionInfo examines a payload from a webhook event, and extracts the repo web URL,
140-
// the revision, and whether or not this affected origin/HEAD (the default branch of the repository)
141-
func affectedRevisionInfo(payloadIf any) (webURLs []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) {
146+
// the revision, and whether, or not this affected origin/HEAD (the default branch of the repository)
147+
func (a *ArgoCDWebhookHandler) affectedRevisionInfo(payloadIf any) (webURLs []string, revision string, change changeInfo, touchedHead bool, changedFiles []string) {
142148
switch payload := payloadIf.(type) {
143149
case azuredevops.GitPushEvent:
144150
// See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push
@@ -189,16 +195,55 @@ func affectedRevisionInfo(payloadIf any) (webURLs []string, revision string, cha
189195
// See: https://confluence.atlassian.com/bitbucket/event-payloads-740262817.html#EventPayloads-Push
190196
// NOTE: this is untested
191197
webURLs = append(webURLs, payload.Repository.Links.HTML.Href)
192-
// TODO: bitbucket includes multiple changes as part of a single event.
193-
// We only pick the first but need to consider how to handle multiple
194-
for _, change := range payload.Push.Changes {
195-
revision = change.New.Name
198+
for _, changes := range payload.Push.Changes {
199+
revision = changes.New.Name
200+
change.shaBefore = changes.Old.Target.Hash
201+
change.shaAfter = changes.New.Target.Hash
196202
break
197203
}
198204
// Not actually sure how to check if the incoming change affected HEAD just by examining the
199205
// payload alone. To be safe, we just return true and let the controller check for himself.
200206
touchedHead = true
201207

208+
// Get DiffSet only for authenticated webhooks.
209+
// when WebhookBitbucketUUID is set in argocd-secret, then the payload must be signed and
210+
// signature is validated before payload is parsed.
211+
if len(a.settings.WebhookBitbucketUUID) > 0 {
212+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
213+
defer cancel()
214+
argoRepo, err := a.lookupRepository(ctx, webURLs[0])
215+
if err != nil {
216+
log.Warnf("error trying to find a matching repo for URL %s: %v", payload.Repository.Links.HTML.Href, err)
217+
break
218+
}
219+
if argoRepo == nil {
220+
// it could be a public repository with no repo creds stored.
221+
// initialize with empty bearer token to use the no auth bitbucket client.
222+
log.Debugf("no bitbucket repository configured for URL %s, initializing with empty bearer token", webURLs[0])
223+
argoRepo = &v1alpha1.Repository{BearerToken: "", Repo: webURLs[0]}
224+
}
225+
apiBaseURL := strings.ReplaceAll(payload.Repository.Links.Self.Href, "/repositories/"+payload.Repository.FullName, "")
226+
bbClient, err := newBitbucketClient(ctx, argoRepo, apiBaseURL)
227+
if err != nil {
228+
log.Warnf("error creating Bitbucket client for repo %s: %v", payload.Repository.Name, err)
229+
break
230+
}
231+
log.Debugf("created bitbucket client with base URL '%s'", apiBaseURL)
232+
owner := strings.ReplaceAll(payload.Repository.FullName, "/"+payload.Repository.Name, "")
233+
spec := change.shaBefore + ".." + change.shaAfter
234+
diffStatChangedFiles, err := fetchDiffStatFromBitbucket(ctx, bbClient, owner, payload.Repository.Name, spec)
235+
if err != nil {
236+
log.Warnf("error fetching changed files using bitbucket diffstat api: %v", err)
237+
}
238+
changedFiles = append(changedFiles, diffStatChangedFiles...)
239+
touchedHead, err = isHeadTouched(ctx, bbClient, owner, payload.Repository.Name, revision)
240+
if err != nil {
241+
log.Warnf("error fetching bitbucket repo details: %v", err)
242+
// To be safe, we just return true and let the controller check for himself.
243+
touchedHead = true
244+
}
245+
}
246+
202247
// Bitbucket does not include a list of changed files anywhere in it's payload
203248
// so we cannot update changedFiles for this type of payload
204249
case bitbucketserver.RepositoryReferenceChangedPayload:
@@ -251,7 +296,7 @@ type changeInfo struct {
251296

252297
// HandleEvent handles webhook events for repo push events
253298
func (a *ArgoCDWebhookHandler) HandleEvent(payload any) {
254-
webURLs, revision, change, touchedHead, changedFiles := affectedRevisionInfo(payload)
299+
webURLs, revision, change, touchedHead, changedFiles := a.affectedRevisionInfo(payload)
255300
// NOTE: the webURL does not include the .git extension
256301
if len(webURLs) == 0 {
257302
log.Info("Ignoring webhook event")
@@ -405,6 +450,23 @@ func (a *ArgoCDWebhookHandler) storePreviouslyCachedManifests(app *v1alpha1.Appl
405450
return nil
406451
}
407452

453+
// lookupRepository returns a repository with its credentials for a given URL. If there are no matching repository secret found,
454+
// then nil repository is returned.
455+
func (a *ArgoCDWebhookHandler) lookupRepository(ctx context.Context, repoURL string) (*v1alpha1.Repository, error) {
456+
repositories, err := a.db.ListRepositories(ctx)
457+
if err != nil {
458+
return nil, fmt.Errorf("error listing repositories: %w", err)
459+
}
460+
var repository *v1alpha1.Repository
461+
for _, repo := range repositories {
462+
if git.SameURL(repo.Repo, repoURL) {
463+
log.Debugf("found a matching repository for URL %s", repoURL)
464+
return repo, nil
465+
}
466+
}
467+
return repository, nil
468+
}
469+
408470
func sourceRevisionHasChanged(source v1alpha1.ApplicationSource, revision string, touchedHead bool) bool {
409471
targetRev := ParseRevision(source.TargetRevision)
410472
if targetRev == "HEAD" || targetRev == "" { // revision is head
@@ -430,6 +492,76 @@ func sourceUsesURL(source v1alpha1.ApplicationSource, webURL string, repoRegexp
430492
return true
431493
}
432494

495+
// newBitbucketClient creates a new bitbucket client for the given repository and uses the provided apiURL to connect
496+
// to the bitbucket server. If the repository uses basic auth, then a basic auth client is created or if bearer token
497+
// is provided, then oauth based client is created.
498+
func newBitbucketClient(_ context.Context, repository *v1alpha1.Repository, apiBaseURL string) (*bb.Client, error) {
499+
var bbClient *bb.Client
500+
if repository.Username != "" && repository.Password != "" {
501+
log.Debugf("fetched user/password for repository URL '%s', initializing basic auth client", repository.Repo)
502+
if repository.Username == "x-token-auth" {
503+
bbClient = bb.NewOAuthbearerToken(repository.Password)
504+
} else {
505+
bbClient = bb.NewBasicAuth(repository.Username, repository.Password)
506+
}
507+
} else {
508+
if repository.BearerToken != "" {
509+
log.Debugf("fetched bearer token for repository URL '%s', initializing bearer token auth based client", repository.Repo)
510+
} else {
511+
log.Debugf("no credentials available for repository URL '%s', initializing no auth client", repository.Repo)
512+
}
513+
bbClient = bb.NewOAuthbearerToken(repository.BearerToken)
514+
}
515+
// parse and set the target URL of the Bitbucket server in the client
516+
repoBaseURL, err := url.Parse(apiBaseURL)
517+
if err != nil {
518+
return nil, fmt.Errorf("failed to parse bitbucket api base URL '%s'", apiBaseURL)
519+
}
520+
bbClient.SetApiBaseURL(*repoBaseURL)
521+
return bbClient, nil
522+
}
523+
524+
// fetchDiffStatFromBitbucket gets the list of files changed between two commits, by making a diffstat api callback to the
525+
// bitbucket server from where the webhook orignated.
526+
func fetchDiffStatFromBitbucket(_ context.Context, bbClient *bb.Client, owner, repoSlug, spec string) ([]string, error) {
527+
// Getting the files changed from diff API:
528+
// https://developer.atlassian.com/cloud/bitbucket/rest/api-group-commits/#api-repositories-workspace-repo-slug-diffstat-spec-get
529+
530+
// invoke the diffstat api call to get the list of changed files between two commit shas
531+
log.Debugf("invoking diffstat call with parameters: [Owner:%s, RepoSlug:%s, Spec:%s]", owner, repoSlug, spec)
532+
diffStatResp, err := bbClient.Repositories.Diff.GetDiffStat(&bb.DiffStatOptions{
533+
Owner: owner,
534+
RepoSlug: repoSlug,
535+
Spec: spec,
536+
Renames: true,
537+
})
538+
if err != nil {
539+
return nil, fmt.Errorf("error getting the diffstat: %w", err)
540+
}
541+
changedFiles := make([]string, len(diffStatResp.DiffStats))
542+
for i, value := range diffStatResp.DiffStats {
543+
changedFilePath := value.New["path"]
544+
if changedFilePath != nil {
545+
changedFiles[i] = changedFilePath.(string)
546+
}
547+
}
548+
log.Debugf("changed files for spec %s: %v", spec, changedFiles)
549+
return changedFiles, nil
550+
}
551+
552+
// isHeadTouched returns true if the repository's main branch is modified, false otherwise
553+
func isHeadTouched(ctx context.Context, bbClient *bb.Client, owner, repoSlug, revision string) (bool, error) {
554+
bbRepoOptions := &bb.RepositoryOptions{
555+
Owner: owner,
556+
RepoSlug: repoSlug,
557+
}
558+
bbRepo, err := bbClient.Repositories.Repository.Get(bbRepoOptions.WithContext(ctx))
559+
if err != nil {
560+
return false, err
561+
}
562+
return bbRepo.Mainbranch.Name == revision, nil
563+
}
564+
433565
func (a *ArgoCDWebhookHandler) Handler(w http.ResponseWriter, r *http.Request) {
434566
var payload any
435567
var err error

0 commit comments

Comments
 (0)