github.com/argoproj/argo-cd/v3@v3.2.1/applicationset/webhook/webhook.go (about) 1 package webhook 2 3 import ( 4 "context" 5 "fmt" 6 "html" 7 "net/http" 8 "net/url" 9 "regexp" 10 "slices" 11 "strconv" 12 "strings" 13 "sync" 14 15 "k8s.io/apimachinery/pkg/types" 16 "k8s.io/client-go/util/retry" 17 "sigs.k8s.io/controller-runtime/pkg/client" 18 19 "github.com/argoproj/argo-cd/v3/applicationset/generators" 20 "github.com/argoproj/argo-cd/v3/common" 21 "github.com/argoproj/argo-cd/v3/pkg/apis/application/v1alpha1" 22 argosettings "github.com/argoproj/argo-cd/v3/util/settings" 23 "github.com/argoproj/argo-cd/v3/util/webhook" 24 25 "github.com/go-playground/webhooks/v6/azuredevops" 26 "github.com/go-playground/webhooks/v6/github" 27 "github.com/go-playground/webhooks/v6/gitlab" 28 log "github.com/sirupsen/logrus" 29 30 "github.com/argoproj/argo-cd/v3/util/guard" 31 ) 32 33 const payloadQueueSize = 50000 34 35 const panicMsgAppSet = "panic while processing applicationset-controller webhook event" 36 37 type WebhookHandler struct { 38 sync.WaitGroup // for testing 39 github *github.Webhook 40 gitlab *gitlab.Webhook 41 azuredevops *azuredevops.Webhook 42 client client.Client 43 generators map[string]generators.Generator 44 queue chan any 45 } 46 47 type gitGeneratorInfo struct { 48 Revision string 49 TouchedHead bool 50 RepoRegexp *regexp.Regexp 51 } 52 53 type prGeneratorInfo struct { 54 Azuredevops *prGeneratorAzuredevopsInfo 55 Github *prGeneratorGithubInfo 56 Gitlab *prGeneratorGitlabInfo 57 } 58 59 type prGeneratorAzuredevopsInfo struct { 60 Repo string 61 Project string 62 } 63 64 type prGeneratorGithubInfo struct { 65 Repo string 66 Owner string 67 APIRegexp *regexp.Regexp 68 } 69 70 type prGeneratorGitlabInfo struct { 71 Project string 72 APIHostname string 73 } 74 75 func NewWebhookHandler(webhookParallelism int, argocdSettingsMgr *argosettings.SettingsManager, client client.Client, generators map[string]generators.Generator) (*WebhookHandler, error) { 76 // register the webhook secrets stored under "argocd-secret" for verifying incoming payloads 77 argocdSettings, err := argocdSettingsMgr.GetSettings() 78 if err != nil { 79 return nil, fmt.Errorf("failed to get argocd settings: %w", err) 80 } 81 githubHandler, err := github.New(github.Options.Secret(argocdSettings.GetWebhookGitHubSecret())) 82 if err != nil { 83 return nil, fmt.Errorf("unable to init GitHub webhook: %w", err) 84 } 85 gitlabHandler, err := gitlab.New(gitlab.Options.Secret(argocdSettings.GetWebhookGitLabSecret())) 86 if err != nil { 87 return nil, fmt.Errorf("unable to init GitLab webhook: %w", err) 88 } 89 azuredevopsHandler, err := azuredevops.New(azuredevops.Options.BasicAuth(argocdSettings.GetWebhookAzureDevOpsUsername(), argocdSettings.GetWebhookAzureDevOpsPassword())) 90 if err != nil { 91 return nil, fmt.Errorf("unable to init Azure DevOps webhook: %w", err) 92 } 93 94 webhookHandler := &WebhookHandler{ 95 github: githubHandler, 96 gitlab: gitlabHandler, 97 azuredevops: azuredevopsHandler, 98 client: client, 99 generators: generators, 100 queue: make(chan any, payloadQueueSize), 101 } 102 103 webhookHandler.startWorkerPool(webhookParallelism) 104 105 return webhookHandler, nil 106 } 107 108 func (h *WebhookHandler) startWorkerPool(webhookParallelism int) { 109 compLog := log.WithField("component", "applicationset-webhook") 110 for i := 0; i < webhookParallelism; i++ { 111 h.Add(1) 112 go func() { 113 defer h.Done() 114 for { 115 payload, ok := <-h.queue 116 if !ok { 117 return 118 } 119 guard.RecoverAndLog(func() { h.HandleEvent(payload) }, compLog, panicMsgAppSet) 120 } 121 }() 122 } 123 } 124 125 func (h *WebhookHandler) HandleEvent(payload any) { 126 gitGenInfo := getGitGeneratorInfo(payload) 127 prGenInfo := getPRGeneratorInfo(payload) 128 if gitGenInfo == nil && prGenInfo == nil { 129 return 130 } 131 132 appSetList := &v1alpha1.ApplicationSetList{} 133 err := h.client.List(context.Background(), appSetList, &client.ListOptions{}) 134 if err != nil { 135 log.Errorf("Failed to list applicationsets: %v", err) 136 return 137 } 138 139 for _, appSet := range appSetList.Items { 140 shouldRefresh := false 141 for _, gen := range appSet.Spec.Generators { 142 // check if the ApplicationSet uses any generator that is relevant to the payload 143 shouldRefresh = shouldRefreshGitGenerator(gen.Git, gitGenInfo) || 144 shouldRefreshPRGenerator(gen.PullRequest, prGenInfo) || 145 shouldRefreshPluginGenerator(gen.Plugin) || 146 h.shouldRefreshMatrixGenerator(gen.Matrix, &appSet, gitGenInfo, prGenInfo) || 147 h.shouldRefreshMergeGenerator(gen.Merge, &appSet, gitGenInfo, prGenInfo) 148 if shouldRefresh { 149 break 150 } 151 } 152 if shouldRefresh { 153 err := refreshApplicationSet(h.client, &appSet) 154 if err != nil { 155 log.Errorf("Failed to refresh ApplicationSet '%s' for controller reprocessing", appSet.Name) 156 continue 157 } 158 log.Infof("refresh ApplicationSet %v/%v from webhook", appSet.Namespace, appSet.Name) 159 } 160 } 161 } 162 163 func (h *WebhookHandler) Handler(w http.ResponseWriter, r *http.Request) { 164 var payload any 165 var err error 166 167 switch { 168 case r.Header.Get("X-GitHub-Event") != "": 169 payload, err = h.github.Parse(r, github.PushEvent, github.PullRequestEvent, github.PingEvent) 170 case r.Header.Get("X-Gitlab-Event") != "": 171 payload, err = h.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents, gitlab.MergeRequestEvents, gitlab.SystemHookEvents) 172 case r.Header.Get("X-Vss-Activityid") != "": 173 payload, err = h.azuredevops.Parse(r, azuredevops.GitPushEventType, azuredevops.GitPullRequestCreatedEventType, azuredevops.GitPullRequestUpdatedEventType, azuredevops.GitPullRequestMergedEventType) 174 default: 175 log.Debug("Ignoring unknown webhook event") 176 http.Error(w, "Unknown webhook event", http.StatusBadRequest) 177 return 178 } 179 180 if err != nil { 181 log.Infof("Webhook processing failed: %s", err) 182 status := http.StatusBadRequest 183 if r.Method != http.MethodPost { 184 status = http.StatusMethodNotAllowed 185 } 186 http.Error(w, "Webhook processing failed: "+html.EscapeString(err.Error()), status) 187 return 188 } 189 190 select { 191 case h.queue <- payload: 192 default: 193 log.Info("Queue is full, discarding webhook payload") 194 http.Error(w, "Queue is full, discarding webhook payload", http.StatusServiceUnavailable) 195 } 196 } 197 198 func getGitGeneratorInfo(payload any) *gitGeneratorInfo { 199 var ( 200 webURL string 201 revision string 202 touchedHead bool 203 ) 204 switch payload := payload.(type) { 205 case github.PushPayload: 206 webURL = payload.Repository.HTMLURL 207 revision = webhook.ParseRevision(payload.Ref) 208 touchedHead = payload.Repository.DefaultBranch == revision 209 case gitlab.PushEventPayload: 210 webURL = payload.Project.WebURL 211 revision = webhook.ParseRevision(payload.Ref) 212 touchedHead = payload.Project.DefaultBranch == revision 213 case azuredevops.GitPushEvent: 214 // See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push 215 webURL = payload.Resource.Repository.RemoteURL 216 revision = webhook.ParseRevision(payload.Resource.RefUpdates[0].Name) 217 touchedHead = payload.Resource.RefUpdates[0].Name == payload.Resource.Repository.DefaultBranch 218 // unfortunately, Azure DevOps doesn't provide a list of changed files 219 default: 220 return nil 221 } 222 223 log.Infof("Received push event repo: %s, revision: %s, touchedHead: %v", webURL, revision, touchedHead) 224 repoRegexp, err := webhook.GetWebURLRegex(webURL) 225 if err != nil { 226 log.Errorf("Failed to compile regexp for repoURL '%s'", webURL) 227 return nil 228 } 229 230 return &gitGeneratorInfo{ 231 RepoRegexp: repoRegexp, 232 TouchedHead: touchedHead, 233 Revision: revision, 234 } 235 } 236 237 func getPRGeneratorInfo(payload any) *prGeneratorInfo { 238 var info prGeneratorInfo 239 switch payload := payload.(type) { 240 case github.PullRequestPayload: 241 if !slices.Contains(githubAllowedPullRequestActions, payload.Action) { 242 return nil 243 } 244 245 apiURL := payload.Repository.URL 246 apiRegexp, err := webhook.GetAPIURLRegex(apiURL) 247 if err != nil { 248 log.Errorf("Failed to compile regexp for repoURL '%s'", apiURL) 249 return nil 250 } 251 info.Github = &prGeneratorGithubInfo{ 252 Repo: payload.Repository.Name, 253 Owner: payload.Repository.Owner.Login, 254 APIRegexp: apiRegexp, 255 } 256 case gitlab.MergeRequestEventPayload: 257 if !slices.Contains(gitlabAllowedPullRequestActions, payload.ObjectAttributes.Action) { 258 return nil 259 } 260 261 apiURL := payload.Project.WebURL 262 urlObj, err := url.Parse(apiURL) 263 if err != nil { 264 log.Errorf("Failed to parse repoURL '%s'", apiURL) 265 return nil 266 } 267 268 info.Gitlab = &prGeneratorGitlabInfo{ 269 Project: strconv.FormatInt(payload.ObjectAttributes.TargetProjectID, 10), 270 APIHostname: urlObj.Hostname(), 271 } 272 case azuredevops.GitPullRequestEvent: 273 if !slices.Contains(azuredevopsAllowedPullRequestActions, string(payload.EventType)) { 274 return nil 275 } 276 277 repo := payload.Resource.Repository.Name 278 project := payload.Resource.Repository.Project.Name 279 280 info.Azuredevops = &prGeneratorAzuredevopsInfo{ 281 Repo: repo, 282 Project: project, 283 } 284 default: 285 return nil 286 } 287 288 return &info 289 } 290 291 // githubAllowedPullRequestActions is a list of github actions that allow refresh 292 var githubAllowedPullRequestActions = []string{ 293 "opened", 294 "closed", 295 "synchronize", 296 "labeled", 297 "reopened", 298 "unlabeled", 299 } 300 301 // gitlabAllowedPullRequestActions is a list of gitlab actions that allow refresh 302 // https://docs.gitlab.com/ee/user/project/integrations/webhook_events.html#merge-request-events 303 var gitlabAllowedPullRequestActions = []string{ 304 "open", 305 "close", 306 "reopen", 307 "update", 308 "merge", 309 } 310 311 // azuredevopsAllowedPullRequestActions is a list of Azure DevOps actions that allow refresh 312 var azuredevopsAllowedPullRequestActions = []string{ 313 "git.pullrequest.created", 314 "git.pullrequest.merged", 315 "git.pullrequest.updated", 316 } 317 318 func shouldRefreshGitGenerator(gen *v1alpha1.GitGenerator, info *gitGeneratorInfo) bool { 319 if gen == nil || info == nil { 320 return false 321 } 322 323 if !gitGeneratorUsesURL(gen, info.Revision, info.RepoRegexp) { 324 return false 325 } 326 if !genRevisionHasChanged(gen, info.Revision, info.TouchedHead) { 327 return false 328 } 329 return true 330 } 331 332 func shouldRefreshPluginGenerator(gen *v1alpha1.PluginGenerator) bool { 333 return gen != nil 334 } 335 336 func genRevisionHasChanged(gen *v1alpha1.GitGenerator, revision string, touchedHead bool) bool { 337 targetRev := webhook.ParseRevision(gen.Revision) 338 if targetRev == "HEAD" || targetRev == "" { // revision is head 339 return touchedHead 340 } 341 342 return targetRev == revision || gen.Revision == revision 343 } 344 345 func gitGeneratorUsesURL(gen *v1alpha1.GitGenerator, webURL string, repoRegexp *regexp.Regexp) bool { 346 if !repoRegexp.MatchString(gen.RepoURL) { 347 log.Warnf("%s does not match %s", gen.RepoURL, repoRegexp.String()) 348 return false 349 } 350 351 log.Debugf("%s uses repoURL %s", gen.RepoURL, webURL) 352 return true 353 } 354 355 func shouldRefreshPRGenerator(gen *v1alpha1.PullRequestGenerator, info *prGeneratorInfo) bool { 356 if gen == nil || info == nil { 357 return false 358 } 359 360 if gen.GitLab != nil && info.Gitlab != nil { 361 if gen.GitLab.Project != info.Gitlab.Project { 362 return false 363 } 364 365 api := gen.GitLab.API 366 if api == "" { 367 api = "https://gitlab.com/" 368 } 369 370 urlObj, err := url.Parse(api) 371 if err != nil { 372 log.Errorf("Failed to parse repoURL '%s'", api) 373 return false 374 } 375 376 if urlObj.Hostname() != info.Gitlab.APIHostname { 377 log.Debugf("%s does not match %s", api, info.Gitlab.APIHostname) 378 return false 379 } 380 381 return true 382 } 383 384 if gen.Github != nil && info.Github != nil { 385 // repository owner and name are case-insensitive 386 // See https://docs.github.com/en/rest/pulls/pulls?apiVersion=2022-11-28#list-pull-requests 387 if !strings.EqualFold(gen.Github.Owner, info.Github.Owner) { 388 return false 389 } 390 if !strings.EqualFold(gen.Github.Repo, info.Github.Repo) { 391 return false 392 } 393 api := gen.Github.API 394 if api == "" { 395 api = "https://api.github.com/" 396 } 397 if !info.Github.APIRegexp.MatchString(api) { 398 log.Debugf("%s does not match %s", api, info.Github.APIRegexp.String()) 399 return false 400 } 401 402 return true 403 } 404 405 if gen.AzureDevOps != nil && info.Azuredevops != nil { 406 if gen.AzureDevOps.Project != info.Azuredevops.Project { 407 return false 408 } 409 if gen.AzureDevOps.Repo != info.Azuredevops.Repo { 410 return false 411 } 412 return true 413 } 414 415 return false 416 } 417 418 func (h *WebhookHandler) shouldRefreshMatrixGenerator(gen *v1alpha1.MatrixGenerator, appSet *v1alpha1.ApplicationSet, gitGenInfo *gitGeneratorInfo, prGenInfo *prGeneratorInfo) bool { 419 if gen == nil { 420 return false 421 } 422 423 // Silently ignore, the ApplicationSetReconciler will log the error as part of the reconcile 424 if len(gen.Generators) < 2 || len(gen.Generators) > 2 { 425 return false 426 } 427 428 g0 := gen.Generators[0] 429 430 // Check first child generator for Git or Pull Request Generator 431 if shouldRefreshGitGenerator(g0.Git, gitGenInfo) || 432 shouldRefreshPRGenerator(g0.PullRequest, prGenInfo) { 433 return true 434 } 435 436 // Check first child generator for nested Matrix generator 437 var matrixGenerator0 *v1alpha1.MatrixGenerator 438 if g0.Matrix != nil { 439 // Since nested matrix generator is represented as a JSON object in the CRD, we unmarshall it back to a Go struct here. 440 nestedMatrix, err := v1alpha1.ToNestedMatrixGenerator(g0.Matrix) 441 if err != nil { 442 log.Errorf("Failed to unmarshall nested matrix generator: %v", err) 443 return false 444 } 445 if nestedMatrix != nil { 446 matrixGenerator0 = nestedMatrix.ToMatrixGenerator() 447 if h.shouldRefreshMatrixGenerator(matrixGenerator0, appSet, gitGenInfo, prGenInfo) { 448 return true 449 } 450 } 451 } 452 453 // Check first child generator for nested Merge generator 454 var mergeGenerator0 *v1alpha1.MergeGenerator 455 if g0.Merge != nil { 456 // Since nested merge generator is represented as a JSON object in the CRD, we unmarshall it back to a Go struct here. 457 nestedMerge, err := v1alpha1.ToNestedMergeGenerator(g0.Merge) 458 if err != nil { 459 log.Errorf("Failed to unmarshall nested merge generator: %v", err) 460 return false 461 } 462 if nestedMerge != nil { 463 mergeGenerator0 = nestedMerge.ToMergeGenerator() 464 if h.shouldRefreshMergeGenerator(mergeGenerator0, appSet, gitGenInfo, prGenInfo) { 465 return true 466 } 467 } 468 } 469 470 // Create ApplicationSetGenerator for first child generator from its ApplicationSetNestedGenerator 471 requestedGenerator0 := &v1alpha1.ApplicationSetGenerator{ 472 List: g0.List, 473 Clusters: g0.Clusters, 474 Git: g0.Git, 475 SCMProvider: g0.SCMProvider, 476 ClusterDecisionResource: g0.ClusterDecisionResource, 477 PullRequest: g0.PullRequest, 478 Plugin: g0.Plugin, 479 Matrix: matrixGenerator0, 480 Merge: mergeGenerator0, 481 } 482 483 // Generate params for first child generator 484 relGenerators := generators.GetRelevantGenerators(requestedGenerator0, h.generators) 485 params := []map[string]any{} 486 for _, g := range relGenerators { 487 p, err := g.GenerateParams(requestedGenerator0, appSet, h.client) 488 if err != nil { 489 log.Error(err) 490 return false 491 } 492 params = append(params, p...) 493 } 494 495 g1 := gen.Generators[1] 496 497 // Create Matrix generator for nested Matrix generator as second child generator 498 var matrixGenerator1 *v1alpha1.MatrixGenerator 499 if g1.Matrix != nil { 500 // Since nested matrix generator is represented as a JSON object in the CRD, we unmarshall it back to a Go struct here. 501 nestedMatrix, err := v1alpha1.ToNestedMatrixGenerator(g1.Matrix) 502 if err != nil { 503 log.Errorf("Failed to unmarshall nested matrix generator: %v", err) 504 return false 505 } 506 if nestedMatrix != nil { 507 matrixGenerator1 = nestedMatrix.ToMatrixGenerator() 508 } 509 } 510 511 // Create Merge generator for nested Merge generator as second child generator 512 var mergeGenerator1 *v1alpha1.MergeGenerator 513 if g1.Merge != nil { 514 // Since nested merge generator is represented as a JSON object in the CRD, we unmarshall it back to a Go struct here. 515 nestedMerge, err := v1alpha1.ToNestedMergeGenerator(g1.Merge) 516 if err != nil { 517 log.Errorf("Failed to unmarshall nested merge generator: %v", err) 518 return false 519 } 520 if nestedMerge != nil { 521 mergeGenerator1 = nestedMerge.ToMergeGenerator() 522 } 523 } 524 525 // Create ApplicationSetGenerator for second child generator from its ApplicationSetNestedGenerator 526 requestedGenerator1 := &v1alpha1.ApplicationSetGenerator{ 527 List: g1.List, 528 Clusters: g1.Clusters, 529 Git: g1.Git, 530 SCMProvider: g1.SCMProvider, 531 ClusterDecisionResource: g1.ClusterDecisionResource, 532 PullRequest: g1.PullRequest, 533 Plugin: g1.Plugin, 534 Matrix: matrixGenerator1, 535 Merge: mergeGenerator1, 536 } 537 538 // Interpolate second child generator with params from first child generator, if there are any params 539 if len(params) != 0 { 540 for _, p := range params { 541 tempInterpolatedGenerator, err := generators.InterpolateGenerator(requestedGenerator1, p, appSet.Spec.GoTemplate, appSet.Spec.GoTemplateOptions) 542 interpolatedGenerator := &tempInterpolatedGenerator 543 if err != nil { 544 log.Error(err) 545 return false 546 } 547 548 // Check all interpolated child generators 549 if shouldRefreshGitGenerator(interpolatedGenerator.Git, gitGenInfo) || 550 shouldRefreshPRGenerator(interpolatedGenerator.PullRequest, prGenInfo) || 551 shouldRefreshPluginGenerator(interpolatedGenerator.Plugin) || 552 h.shouldRefreshMatrixGenerator(interpolatedGenerator.Matrix, appSet, gitGenInfo, prGenInfo) || 553 h.shouldRefreshMergeGenerator(requestedGenerator1.Merge, appSet, gitGenInfo, prGenInfo) { 554 return true 555 } 556 } 557 } 558 559 // First child generator didn't return any params, just check the second child generator 560 return shouldRefreshGitGenerator(requestedGenerator1.Git, gitGenInfo) || 561 shouldRefreshPRGenerator(requestedGenerator1.PullRequest, prGenInfo) || 562 shouldRefreshPluginGenerator(requestedGenerator1.Plugin) || 563 h.shouldRefreshMatrixGenerator(requestedGenerator1.Matrix, appSet, gitGenInfo, prGenInfo) || 564 h.shouldRefreshMergeGenerator(requestedGenerator1.Merge, appSet, gitGenInfo, prGenInfo) 565 } 566 567 func (h *WebhookHandler) shouldRefreshMergeGenerator(gen *v1alpha1.MergeGenerator, appSet *v1alpha1.ApplicationSet, gitGenInfo *gitGeneratorInfo, prGenInfo *prGeneratorInfo) bool { 568 if gen == nil { 569 return false 570 } 571 572 for _, g := range gen.Generators { 573 // Check Git or Pull Request generator 574 if shouldRefreshGitGenerator(g.Git, gitGenInfo) || 575 shouldRefreshPRGenerator(g.PullRequest, prGenInfo) { 576 return true 577 } 578 579 // Check nested Matrix generator 580 if g.Matrix != nil { 581 // Since nested matrix generator is represented as a JSON object in the CRD, we unmarshall it back to a Go struct here. 582 nestedMatrix, err := v1alpha1.ToNestedMatrixGenerator(g.Matrix) 583 if err != nil { 584 log.Errorf("Failed to unmarshall nested matrix generator: %v", err) 585 return false 586 } 587 if nestedMatrix != nil { 588 if h.shouldRefreshMatrixGenerator(nestedMatrix.ToMatrixGenerator(), appSet, gitGenInfo, prGenInfo) { 589 return true 590 } 591 } 592 } 593 594 // Check nested Merge generator 595 if g.Merge != nil { 596 // Since nested merge generator is represented as a JSON object in the CRD, we unmarshall it back to a Go struct here. 597 nestedMerge, err := v1alpha1.ToNestedMergeGenerator(g.Merge) 598 if err != nil { 599 log.Errorf("Failed to unmarshall nested merge generator: %v", err) 600 return false 601 } 602 if nestedMerge != nil { 603 if h.shouldRefreshMergeGenerator(nestedMerge.ToMergeGenerator(), appSet, gitGenInfo, prGenInfo) { 604 return true 605 } 606 } 607 } 608 } 609 610 return false 611 } 612 613 func refreshApplicationSet(c client.Client, appSet *v1alpha1.ApplicationSet) error { 614 // patch the ApplicationSet with the refresh annotation to reconcile 615 return retry.RetryOnConflict(retry.DefaultBackoff, func() error { 616 err := c.Get(context.Background(), types.NamespacedName{Name: appSet.Name, Namespace: appSet.Namespace}, appSet) 617 if err != nil { 618 return fmt.Errorf("error getting ApplicationSet: %w", err) 619 } 620 if appSet.Annotations == nil { 621 appSet.Annotations = map[string]string{} 622 } 623 appSet.Annotations[common.AnnotationApplicationSetRefresh] = "true" 624 return c.Patch(context.Background(), appSet, client.Merge) 625 }) 626 }