From 98bfc7ce65b5509de8291954e62908f12587d4c9 Mon Sep 17 00:00:00 2001 From: Robin Lieb Date: Mon, 14 Aug 2023 20:04:09 +0200 Subject: [PATCH] feat(appset): add Support for AzureDevops Webhooks Signed-off-by: Robin Lieb --- .../testdata/azuredevops-pull-request.json | 85 ++++++++++++++ .../webhook/testdata/azuredevops-push.json | 76 +++++++++++++ applicationset/webhook/webhook.go | 105 ++++++++++++++++-- applicationset/webhook/webhook_test.go | 41 +++++++ 4 files changed, 295 insertions(+), 12 deletions(-) create mode 100644 applicationset/webhook/testdata/azuredevops-pull-request.json create mode 100644 applicationset/webhook/testdata/azuredevops-push.json diff --git a/applicationset/webhook/testdata/azuredevops-pull-request.json b/applicationset/webhook/testdata/azuredevops-pull-request.json new file mode 100644 index 0000000000000..80c5e7cb90822 --- /dev/null +++ b/applicationset/webhook/testdata/azuredevops-pull-request.json @@ -0,0 +1,85 @@ +{ + "id": "2ab4e3d3-b7a6-425e-92b1-5a9982c1269e", + "eventType": "git.pullrequest.created", + "publisherId": "tfs", + "scope": "all", + "message": { + "text": "Jamal Hartnett created a new pull request", + "html": "Jamal Hartnett created a new pull request", + "markdown": "Jamal Hartnett created a new pull request" + }, + "detailedMessage": { + "text": "Jamal Hartnett created a new pull request\r\n\r\n- Merge status: Succeeded\r\n- Merge commit: eef717(https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079/commits/eef717f69257a6333f221566c1c987dc94cc0d72)\r\n", + "html": "Jamal Hartnett created a new pull request\r\n", + "markdown": "Jamal Hartnett created a new pull request\r\n\r\n+ Merge status: Succeeded\r\n+ Merge commit: [eef717](https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079/commits/eef717f69257a6333f221566c1c987dc94cc0d72)\r\n" + }, + "resource": { + "repository": { + "id": "4bc14d40-c903-45e2-872e-0462c7748079", + "name": "Fabrikam", + "url": "https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079", + "project": { + "id": "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c", + "name": "DefaultCollection", + "url": "https://dev.azure.com/fabrikam/DefaultCollection/_apis/projects/6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c", + "state": "wellFormed" + }, + "defaultBranch": "refs/heads/master", + "remoteUrl": "https://dev.azure.com/fabrikam/DefaultCollection/_git/Fabrikam" + }, + "pullRequestId": 1, + "status": "active", + "createdBy": { + "id": "54d125f7-69f7-4191-904f-c5b96b6261c8", + "displayName": "Jamal Hartnett", + "uniqueName": "fabrikamfiber4@hotmail.com", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/54d125f7-69f7-4191-904f-c5b96b6261c8", + "imageUrl": "https://dev.azure.com/fabrikam/DefaultCollection/_api/_common/identityImage?id=54d125f7-69f7-4191-904f-c5b96b6261c8" + }, + "creationDate": "2014-06-17T16:55:46.589889Z", + "title": "my first pull request", + "description": " - test2\r\n", + "sourceRefName": "refs/heads/mytopic", + "targetRefName": "refs/heads/master", + "mergeStatus": "succeeded", + "mergeId": "a10bb228-6ba6-4362-abd7-49ea21333dbd", + "lastMergeSourceCommit": { + "commitId": "53d54ac915144006c2c9e90d2c7d3880920db49c", + "url": "https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079/commits/53d54ac915144006c2c9e90d2c7d3880920db49c" + }, + "lastMergeTargetCommit": { + "commitId": "a511f535b1ea495ee0c903badb68fbc83772c882", + "url": "https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079/commits/a511f535b1ea495ee0c903badb68fbc83772c882" + }, + "lastMergeCommit": { + "commitId": "eef717f69257a6333f221566c1c987dc94cc0d72", + "url": "https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079/commits/eef717f69257a6333f221566c1c987dc94cc0d72" + }, + "reviewers": [ + { + "reviewerUrl": null, + "vote": 0, + "id": "2ea2d095-48f9-4cd6-9966-62f6f574096c", + "displayName": "[Mobile]\\Mobile Team", + "uniqueName": "vstfs:///Classification/TeamProject/f0811a3b-8c8a-4e43-a3bf-9a049b4835bd\\Mobile Team", + "url": "https://vssps.dev.azure.com/fabrikam/_apis/Identities/2ea2d095-48f9-4cd6-9966-62f6f574096c", + "imageUrl": "https://dev.azure.com/fabrikam/DefaultCollection/_api/_common/identityImage?id=2ea2d095-48f9-4cd6-9966-62f6f574096c", + "isContainer": true + } + ], + "url": "https://dev.azure.com/fabrikam/DefaultCollection/_apis/repos/git/repositories/4bc14d40-c903-45e2-872e-0462c7748079/pullRequests/1" + }, + "resourceVersion": "1.0", + "resourceContainers": { + "collection": { + "id": "c12d0eb8-e382-443b-9f9c-c52cba5014c2" + }, + "account": { + "id": "f844ec47-a9db-4511-8281-8b63f4eaf94e" + }, + "project": { + "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f" + } + }, + "createdDate": "2016-09-19T13:03:27.2879096Z" + } \ No newline at end of file diff --git a/applicationset/webhook/testdata/azuredevops-push.json b/applicationset/webhook/testdata/azuredevops-push.json new file mode 100644 index 0000000000000..41ee074892e39 --- /dev/null +++ b/applicationset/webhook/testdata/azuredevops-push.json @@ -0,0 +1,76 @@ +{ + "id": "03c164c2-8912-4d5e-8009-3707d5f83734", + "eventType": "git.push", + "publisherId": "tfs", + "scope": "all", + "message": { + "text": "Jamal Hartnett pushed updates to branch master of repository Fabrikam-Fiber-Git.", + "html": "Jamal Hartnett pushed updates to branch master of repository Fabrikam-Fiber-Git.", + "markdown": "Jamal Hartnett pushed updates to branch `master` of repository `Fabrikam-Fiber-Git`." + }, + "detailedMessage": { + "text": "Jamal Hartnett pushed 1 commit to branch master of repository Fabrikam-Fiber-Git.\n - Fixed bug in web.config file 33b55f7c", + "html": "Jamal Hartnett pushed 1 commit to branch master of repository Fabrikam-Fiber-Git.\n", + "markdown": "Jamal Hartnett pushed 1 commit to branch [master](https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_git/Fabrikam-Fiber-Git/#version=GBmaster) of repository [Fabrikam-Fiber-Git](https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_git/Fabrikam-Fiber-Git/).\n* Fixed bug in web.config file [33b55f7c](https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74)" + }, + "resource": { + "commits": [ + { + "commitId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74", + "author": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "committer": { + "name": "Jamal Hartnett", + "email": "fabrikamfiber4@hotmail.com", + "date": "2015-02-25T19:01:00Z" + }, + "comment": "Fixed bug in web.config file", + "url": "https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_git/Fabrikam-Fiber-Git/commit/33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "refUpdates": [ + { + "name": "refs/heads/master", + "oldObjectId": "aad331d8d3b131fa9ae03cf5e53965b51942618a", + "newObjectId": "33b55f7cb7e7e245323987634f960cf4a6e6bc74" + } + ], + "repository": { + "id": "278d5cd2-584d-4b63-824a-2ba458937249", + "name": "Fabrikam-Fiber-Git", + "url": "https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_apis/repos/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249", + "project": { + "id": "6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c", + "name": "DefaultCollection", + "url": "https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_apis/projects/6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c", + "state": "wellFormed" + }, + "defaultBranch": "refs/heads/master", + "remoteUrl": "https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_git/Fabrikam-Fiber-Git" + }, + "pushedBy": { + "id": "00067FFED5C7AF52@Live.com", + "displayName": "Jamal Hartnett", + "uniqueName": "Windows Live ID\\fabrikamfiber4@hotmail.com" + }, + "pushId": 14, + "date": "2014-05-02T19:17:13.3309587Z", + "url": "https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_apis/repos/git/repositories/278d5cd2-584d-4b63-824a-2ba458937249/pushes/14" + }, + "resourceVersion": "1.0", + "resourceContainers": { + "collection": { + "id": "c12d0eb8-e382-443b-9f9c-c52cba5014c2" + }, + "account": { + "id": "f844ec47-a9db-4511-8281-8b63f4eaf94e" + }, + "project": { + "id": "be9b3917-87e6-42a4-a549-2bc06a7a878f" + } + }, + "createdDate": "2016-09-19T13:03:27.0379153Z" + } \ No newline at end of file diff --git a/applicationset/webhook/webhook.go b/applicationset/webhook/webhook.go index ed48997db813e..ce099df35ea35 100644 --- a/applicationset/webhook/webhook.go +++ b/applicationset/webhook/webhook.go @@ -2,6 +2,7 @@ package webhook import ( "context" + "errors" "fmt" "html" "net/http" @@ -19,17 +20,24 @@ import ( "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" argosettings "github.com/argoproj/argo-cd/v2/util/settings" + "github.com/go-playground/webhooks/v6/azuredevops" "github.com/go-playground/webhooks/v6/github" "github.com/go-playground/webhooks/v6/gitlab" log "github.com/sirupsen/logrus" ) +var ( + errBasicAuthVerificationFailed = errors.New("basic auth verification failed") +) + type WebhookHandler struct { - namespace string - github *github.Webhook - gitlab *gitlab.Webhook - client client.Client - generators map[string]generators.Generator + namespace string + github *github.Webhook + gitlab *gitlab.Webhook + azuredevops *azuredevops.Webhook + azuredevopsAuthHandler func(r *http.Request) error + client client.Client + generators map[string]generators.Generator } type gitGeneratorInfo struct { @@ -39,8 +47,14 @@ type gitGeneratorInfo struct { } type prGeneratorInfo struct { - Github *prGeneratorGithubInfo - Gitlab *prGeneratorGitlabInfo + Azuredevops *prGeneratorAzuredevopsInfo + Github *prGeneratorGithubInfo + Gitlab *prGeneratorGitlabInfo +} + +type prGeneratorAzuredevopsInfo struct { + Repo string + Project string } type prGeneratorGithubInfo struct { @@ -68,13 +82,28 @@ func NewWebhookHandler(namespace string, argocdSettingsMgr *argosettings.Setting if err != nil { return nil, fmt.Errorf("Unable to init GitLab webhook: %v", err) } + azuredevopsHandler, err := azuredevops.New() + if err != nil { + return nil, fmt.Errorf("Unable to init Azure DevOps webhook: %v", err) + } + azuredevopsAuthHandler := func(r *http.Request) error { + if argocdSettings.WebhookAzureDevOpsUsername != "" && argocdSettings.WebhookAzureDevOpsPassword != "" { + username, password, ok := r.BasicAuth() + if !ok || username != argocdSettings.WebhookAzureDevOpsUsername || password != argocdSettings.WebhookAzureDevOpsPassword { + return errBasicAuthVerificationFailed + } + } + return nil + } return &WebhookHandler{ - namespace: namespace, - github: githubHandler, - gitlab: gitlabHandler, - client: client, - generators: generators, + namespace: namespace, + github: githubHandler, + gitlab: gitlabHandler, + azuredevops: azuredevopsHandler, + azuredevopsAuthHandler: azuredevopsAuthHandler, + client: client, + generators: generators, }, nil } @@ -125,6 +154,14 @@ func (h *WebhookHandler) Handler(w http.ResponseWriter, r *http.Request) { payload, err = h.github.Parse(r, github.PushEvent, github.PullRequestEvent, github.PingEvent) case r.Header.Get("X-Gitlab-Event") != "": payload, err = h.gitlab.Parse(r, gitlab.PushEvents, gitlab.TagEvents, gitlab.MergeRequestEvents) + case r.Header.Get("X-Vss-Activityid") != "": + if err = h.azuredevopsAuthHandler(r); err != nil { + if errors.Is(err, errBasicAuthVerificationFailed) { + log.WithField(common.SecurityField, common.SecurityHigh).Infof("Azure DevOps webhook basic auth verification failed") + } + } else { + payload, err = h.azuredevops.Parse(r, azuredevops.GitPushEventType, azuredevops.GitPullRequestCreatedEventType, azuredevops.GitPullRequestUpdatedEventType, azuredevops.GitPullRequestMergedEventType) + } default: log.Debug("Ignoring unknown webhook event") http.Error(w, "Unknown webhook event", http.StatusBadRequest) @@ -164,6 +201,12 @@ func getGitGeneratorInfo(payload interface{}) *gitGeneratorInfo { webURL = payload.Project.WebURL revision = parseRevision(payload.Ref) touchedHead = payload.Project.DefaultBranch == revision + case azuredevops.GitPushEvent: + // See: https://learn.microsoft.com/en-us/azure/devops/service-hooks/events?view=azure-devops#git.push + webURL = payload.Resource.Repository.RemoteURL + revision = parseRevision(payload.Resource.RefUpdates[0].Name) + touchedHead = payload.Resource.RefUpdates[0].Name == payload.Resource.Repository.DefaultBranch + // unfortunately, Azure DevOps doesn't provide a list of changed files default: return nil } @@ -229,6 +272,18 @@ func getPRGeneratorInfo(payload interface{}) *prGeneratorInfo { Project: strconv.FormatInt(payload.ObjectAttributes.TargetProjectID, 10), APIHostname: urlObj.Hostname(), } + case azuredevops.GitPullRequestEvent: + if !isAllowedAzureDevOpsPullRequestAction(string(payload.EventType)) { + return nil + } + + repo := payload.Resource.Repository.Name + project := payload.Resource.Repository.Project.Name + + info.Azuredevops = &prGeneratorAzuredevopsInfo{ + Repo: repo, + Project: project, + } default: return nil } @@ -256,6 +311,13 @@ var gitlabAllowedPullRequestActions = []string{ "merge", } +// azuredevopsAllowedPullRequestActions is a list of Azure DevOps actions that allow refresh +var azuredevopsAllowedPullRequestActions = []string{ + "git.pullrequest.created", + "git.pullrequest.merged", + "git.pullrequest.updated", +} + func isAllowedGithubPullRequestAction(action string) bool { for _, allow := range githubAllowedPullRequestActions { if allow == action { @@ -274,6 +336,15 @@ func isAllowedGitlabPullRequestAction(action string) bool { return false } +func isAllowedAzureDevOpsPullRequestAction(action string) bool { + for _, allow := range azuredevopsAllowedPullRequestActions { + if allow == action { + return true + } + } + return false +} + func shouldRefreshGitGenerator(gen *v1alpha1.GitGenerator, info *gitGeneratorInfo) bool { if gen == nil || info == nil { return false @@ -359,6 +430,16 @@ func shouldRefreshPRGenerator(gen *v1alpha1.PullRequestGenerator, info *prGenera return true } + if gen.AzureDevOps != nil && info.Azuredevops != nil { + if gen.AzureDevOps.Project != info.Azuredevops.Project { + return false + } + if gen.AzureDevOps.Repo != info.Azuredevops.Repo { + return false + } + return true + } + return false } diff --git a/applicationset/webhook/webhook_test.go b/applicationset/webhook/webhook_test.go index 2d683762d7170..349d275948aee 100644 --- a/applicationset/webhook/webhook_test.go +++ b/applicationset/webhook/webhook_test.go @@ -146,6 +146,24 @@ func TestWebhookHandler(t *testing.T) { expectedStatusCode: http.StatusOK, expectedRefresh: false, }, + { + desc: "WebHook from a Azure DevOps repository via Commit", + headerKey: "X-Vss-Activityid", + headerValue: "Push Hook", + payloadFile: "azuredevops-push.json", + effectedAppSets: []string{"git-azure-devops", "plugin", "matrix-pull-request-github-plugin"}, + expectedStatusCode: http.StatusOK, + expectedRefresh: true, + }, + { + desc: "WebHook from a Azure DevOps repository via pull request event", + headerKey: "X-Vss-Activityid", + headerValue: "Pull Request Hook", + payloadFile: "azuredevops-pull-request.json", + effectedAppSets: []string{"pull-request-azure-devops", "plugin", "matrix-pull-request-github-plugin"}, + expectedStatusCode: http.StatusOK, + expectedRefresh: true, + }, } namespace := "test" @@ -161,8 +179,10 @@ func TestWebhookHandler(t *testing.T) { fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects( fakeAppWithGitGenerator("git-github", namespace, "https://github.com/org/repo"), fakeAppWithGitGenerator("git-gitlab", namespace, "https://gitlab/group/name"), + fakeAppWithGitGenerator("git-azure-devops", namespace, "https://dev.azure.com/fabrikam-fiber-inc/DefaultCollection/_git/Fabrikam-Fiber-Git"), fakeAppWithGithubPullRequestGenerator("pull-request-github", namespace, "Codertocat", "Hello-World"), fakeAppWithGitlabPullRequestGenerator("pull-request-gitlab", namespace, "100500"), + fakeAppWithAzureDevOpsPullRequestGenerator("pull-request-azure-devops", namespace, "DefaultCollection", "Fabrikam"), fakeAppWithPluginGenerator("plugin", namespace), fakeAppWithMatrixAndGitGenerator("matrix-git-github", namespace, "https://github.com/org/repo"), fakeAppWithMatrixAndPullRequestGenerator("matrix-pull-request-github", namespace, "Codertocat", "Hello-World"), @@ -338,6 +358,27 @@ func fakeAppWithGithubPullRequestGenerator(name, namespace, owner, repo string) } } +func fakeAppWithAzureDevOpsPullRequestGenerator(name, namespace, project, repo string) *v1alpha1.ApplicationSet { + return &v1alpha1.ApplicationSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: v1alpha1.ApplicationSetSpec{ + Generators: []v1alpha1.ApplicationSetGenerator{ + { + PullRequest: &v1alpha1.PullRequestGenerator{ + AzureDevOps: &v1alpha1.PullRequestGeneratorAzureDevOps{ + Project: project, + Repo: repo, + }, + }, + }, + }, + }, + } +} + func fakeAppWithMatrixAndGitGenerator(name, namespace, repo string) *v1alpha1.ApplicationSet { return &v1alpha1.ApplicationSet{ ObjectMeta: metav1.ObjectMeta{