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 }