diff --git a/go.mod b/go.mod index a5a463a7..ee8139d1 100644 --- a/go.mod +++ b/go.mod @@ -70,6 +70,7 @@ require ( github.com/go-openapi/jsonpointer v0.19.6 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-playground/webhooks/v6 v6.3.0 // indirect github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect diff --git a/go.sum b/go.sum index bd9e385b..228a6340 100644 --- a/go.sum +++ b/go.sum @@ -314,6 +314,8 @@ github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-playground/webhooks/v6 v6.3.0 h1:zBLUxK1Scxwi97TmZt5j/B/rLlard2zY7P77FHg58FE= +github.com/go-playground/webhooks/v6 v6.3.0/go.mod h1:GCocmfMtpJdkEOM1uG9p2nXzg1kY5X/LtvQgtPHUaaA= github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= @@ -328,6 +330,7 @@ github.com/godbus/dbus v0.0.0-20190422162347-ade71ed3457e/go.mod h1:bBOAhwG1umN6 github.com/godbus/dbus/v5 v5.0.3/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogits/go-gogs-client v0.0.0-20200905025246-8bb8a50cb355/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0= github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 h1:04sojTxgYxu1L4Hn7Tgf7UVtIosVa6CuHtvNY+7T1K4= github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85/go.mod h1:cY2AIrMgHm6oOHmR7jY+9TtjzSjQ3iG7tURJG3Y6XH0= github.com/gogo/googleapis v1.2.0/go.mod h1:Njal3psf3qN6dwBtQfUmBZh2ybovJ0tlu3o/AC7HYjU= diff --git a/pkg/webhook/azuredevops/webhook.go b/pkg/webhook/azuredevops/webhook.go new file mode 100644 index 00000000..4c301f0a --- /dev/null +++ b/pkg/webhook/azuredevops/webhook.go @@ -0,0 +1,110 @@ +// File copied from https://github.com/go-playground/webhooks/blob/master/azuredevops/azuredevops.go +// TODO Basic Auth is added here since it's not available upstream. Remove ths file once https://github.com/go-playground/webhooks/pull/191 is merged + +package azuredevops + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/go-playground/webhooks/v6/azuredevops" +) + +// parse errors +var ( + ErrInvalidHTTPMethod = errors.New("invalid HTTP Method") + ErrParsingPayload = errors.New("error parsing payload") + ErrBasicAuthVerificationFailed = errors.New("basic auth verification failed") +) + +// Option is a configuration option for the webhook +type Option func(*Webhook) error + +// Options is a namespace var for configuration options +var Options = WebhookOptions{} + +// WebhookOptions is a namespace for configuration option methods +type WebhookOptions struct{} + +// BasicAuth verifies payload using basic auth +func (WebhookOptions) BasicAuth(username, password string) Option { + return func(hook *Webhook) error { + hook.username = username + hook.password = password + return nil + } +} + +// Webhook instance contains all methods needed to process events +type Webhook struct { + username string + password string +} + +// New creates and returns a WebHook instance +func New(options ...Option) (*Webhook, error) { + hook := new(Webhook) + for _, opt := range options { + if err := opt(hook); err != nil { + return nil, errors.New("Error applying Option") + } + } + return hook, nil +} + +// Parse verifies and parses the events specified and returns the payload object or an error +func (hook Webhook) Parse(r *http.Request, events ...azuredevops.Event) (interface{}, error) { + defer func() { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + }() + + if !hook.verifyBasicAuth(r) { + return nil, ErrBasicAuthVerificationFailed + } + + if r.Method != http.MethodPost { + return nil, ErrInvalidHTTPMethod + } + + payload, err := io.ReadAll(r.Body) + if err != nil || len(payload) == 0 { + return nil, ErrParsingPayload + } + + var pl azuredevops.BasicEvent + err = json.Unmarshal([]byte(payload), &pl) + if err != nil { + return nil, ErrParsingPayload + } + + switch pl.EventType { + case azuredevops.GitPushEventType: + var fpl azuredevops.GitPushEvent + err = json.Unmarshal([]byte(payload), &fpl) + return fpl, err + case azuredevops.GitPullRequestCreatedEventType, azuredevops.GitPullRequestMergedEventType, azuredevops.GitPullRequestUpdatedEventType: + var fpl azuredevops.GitPullRequestEvent + err = json.Unmarshal([]byte(payload), &fpl) + return fpl, err + case azuredevops.BuildCompleteEventType: + var fpl azuredevops.BuildCompleteEvent + err = json.Unmarshal([]byte(payload), &fpl) + return fpl, err + default: + return nil, fmt.Errorf("unknown event %s", pl.EventType) + } +} + +func (hook Webhook) verifyBasicAuth(r *http.Request) bool { + // skip validation if username or password was not provided + if hook.username == "" && hook.password == "" { + return true + } + username, password, ok := r.BasicAuth() + + return ok && username == hook.username && password == hook.password +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index ed8c5a95..911a1cfd 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -7,6 +7,9 @@ import ( "regexp" "strings" + goPlaygroundAzuredevops "github.com/go-playground/webhooks/v6/azuredevops" + "github.com/rancher/gitjob/pkg/webhook/azuredevops" + "github.com/Masterminds/semver/v3" gogsclient "github.com/gogits/go-gogs-client" "github.com/gorilla/mux" @@ -31,6 +34,8 @@ const ( bitbucketKey = "bitbucket" bitbucketServerKey = "bitbucket-server" gogsKey = "gogs" + azureUsername = "azure-username" + azurePassword = "azure-password" branchRefPrefix = "refs/heads/" tagRefPrefix = "refs/tags/" @@ -46,6 +51,7 @@ type Webhook struct { bitbucket *bitbucket.Webhook bitbucketServer *bitbucketserver.Webhook gogs *gogs.Webhook + azureDevops *azuredevops.Webhook } func New(ctx context.Context, rContext *types.Context) *Webhook { @@ -89,6 +95,11 @@ func (w *Webhook) onSecretChange(_ string, secret *corev1.Secret) (*corev1.Secre if err != nil { return nil, err } + w.azureDevops, err = azuredevops.New(azuredevops.Options.BasicAuth(string(secret.Data[azureUsername]), string(secret.Data[azurePassword]))) + if err != nil { + return nil, err + } + return nil, nil } @@ -109,6 +120,8 @@ func (w *Webhook) ServeHTTP(rw http.ResponseWriter, r *http.Request) { payload, err = w.bitbucket.Parse(r, bitbucket.RepoPushEvent) case r.Header.Get("X-Event-Key") != "": payload, err = w.bitbucketServer.Parse(r, bitbucketserver.RepositoryReferenceChangedEvent) + case r.Header.Get("X-Vss-Activityid") != "" || r.Header.Get("X-Vss-Subscriptionid") != "": + payload, err = w.azureDevops.Parse(r, goPlaygroundAzuredevops.GitPushEventType) default: logrus.Debug("Ignoring unknown webhook event") return @@ -168,6 +181,13 @@ func (w *Webhook) ServeHTTP(rw http.ResponseWriter, r *http.Request) { repoURLs = append(repoURLs, t.Repo.HTMLURL) branch, tag = getBranchTagFromRef(t.Ref) revision = t.After + case goPlaygroundAzuredevops.GitPushEvent: + repoURLs = append(repoURLs, t.Resource.Repository.RemoteURL) + for _, refUpdate := range t.Resource.RefUpdates { + branch, tag = getBranchTagFromRef(refUpdate.Name) + revision = refUpdate.NewObjectID + break + } } gitjobs, err := w.gitjobs.Cache().List("", labels.Everything()) diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go index be7df69e..1fcf30cd 100644 --- a/pkg/webhook/webhook_test.go +++ b/pkg/webhook/webhook_test.go @@ -1,6 +1,16 @@ package webhook import ( + "bytes" + + "github.com/golang/mock/gomock" + v1 "github.com/rancher/gitjob/pkg/apis/gitjob.cattle.io/v1" + "github.com/rancher/gitjob/pkg/webhook/azuredevops" + "github.com/rancher/wrangler/v2/pkg/generic/fake" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + + "net/http" "testing" "gotest.tools/assert" @@ -31,3 +41,54 @@ func TestGetBranchTagFromRef(t *testing.T) { assert.Equal(t, tag, outputs[i][1]) } } + +func TestAzureDevopsWebhook(t *testing.T) { + const commit = "f00c3a181697bb3829a6462e931c7456bbed557b" + const repoURL = "https://dev.azure.com/fleet/git-test/_git/git-test" + ctrl := gomock.NewController(t) + mockGitjob := fake.NewMockControllerInterface[*v1.GitJob, *v1.GitJobList](ctrl) + mockGitjobCache := fake.NewMockCacheInterface[*v1.GitJob](ctrl) + gitjob := &v1.GitJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: v1.GitJobSpec{ + Git: v1.GitInfo{ + Repo: repoURL, + Branch: "main", + }, + }, + } + gitjobs := []*v1.GitJob{gitjob} + w := &Webhook{gitjobs: mockGitjob} + w.azureDevops, _ = azuredevops.New() + jsonBody := []byte(`{"subscriptionId":"xxx","notificationId":1,"id":"xxx","eventType":"git.push","publisherId":"tfs","message":{"text":"commit pushed","html":"commit pushed"},"detailedMessage":{"text":"pushed a commit to git-test"},"resource":{"commits":[{"commitId":"` + commit + `","author":{"name":"fleet","email":"fleet@suse.com","date":"2024-01-05T10:16:56Z"},"committer":{"name":"fleet","email":"fleet@suse.com","date":"2024-01-05T10:16:56Z"},"comment":"test commit","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/commits/f00c3a181697bb3829a6462e931c7456bbed557b"}],"refUpdates":[{"name":"refs/heads/main","oldObjectId":"135f8a827edae980466f72eef385881bb4e158d8","newObjectId":"` + commit + `"}],"repository":{"id":"xxx","name":"git-test","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx","project":{"id":"xxx","name":"git-test","url":"https://dev.azure.com/fleet/_apis/projects/xxx","state":"wellFormed","visibility":"unchanged","lastUpdateTime":"0001-01-01T00:00:00"},"defaultBranch":"refs/heads/main","remoteUrl":"` + repoURL + `"},"pushedBy":{"displayName":"Fleet","url":"https://spsprodneu1.vssps.visualstudio.com/xxx/_apis/Identities/xxx","_links":{"avatar":{"href":"https://dev.azure.com/fleet/_apis/GraphProfile/MemberAvatars/msa.xxxx"}},"id":"xxx","uniqueName":"fleet@suse.com","imageUrl":"https://dev.azure.com/fleet/_api/_common/identityImage?id=xxx","descriptor":"xxxx"},"pushId":22,"date":"2024-01-05T10:17:18.735088Z","url":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22","_links":{"self":{"href":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22"},"repository":{"href":"https://dev.azure.com/fleet/xxx/_apis/git/repositories/xxx"},"commits":{"href":"https://dev.azure.com/fleet/_apis/git/repositories/xxx/pushes/22/commits"},"pusher":{"href":"https://spsprodneu1.vssps.visualstudio.com/xxx/_apis/Identities/xxx"},"refs":{"href":"https://dev.azure.com/fleet/xxx/_apis/git/repositories/xxx/refs/heads/main"}}},"resourceVersion":"1.0","resourceContainers":{"collection":{"id":"xxx","baseUrl":"https://dev.azure.com/fleet/"},"account":{"id":"ec365173-fce3-4dfc-8fc2-950f0b5728b1","baseUrl":"https://dev.azure.com/fleet/"},"project":{"id":"xxx","baseUrl":"https://dev.azure.com/fleet/"}},"createdDate":"2024-01-05T10:17:26.0098694Z"}`) + bodyReader := bytes.NewReader(jsonBody) + req, err := http.NewRequest(http.MethodPost, repoURL, bodyReader) + if err != nil { + t.Errorf("unexpected err %v", err) + } + h := http.Header{} + h.Add("X-Vss-Activityid", "xxx") + req.Header = h + + expectedStatusUpdate := gitjob.DeepCopy() + expectedStatusUpdate.Status.Commit = commit + mockGitjobCache.EXPECT().List("", labels.Everything()).Return(gitjobs, nil) + mockGitjob.EXPECT().Cache().Return(mockGitjobCache) + mockGitjob.EXPECT().UpdateStatus(expectedStatusUpdate).Return(gitjob, nil) + mockGitjob.EXPECT().Update(gitjob) + + w.ServeHTTP(&responseWriter{}, req) +} + +type responseWriter struct{} + +func (r *responseWriter) Header() http.Header { + return http.Header{} +} +func (r *responseWriter) Write([]byte) (int, error) { + return 0, nil +} + +func (r *responseWriter) WriteHeader(statusCode int) {}