Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add azure devops webhook support #401

Merged
merged 3 commits into from
Jan 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ replace (

require (
github.com/Masterminds/semver/v3 v3.2.1
github.com/go-playground/webhooks/v6 v6.3.0
github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85
github.com/golang/mock v1.6.0
github.com/gorilla/mux v1.8.0
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -268,11 +268,14 @@ github.com/go-openapi/validate v0.18.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+
github.com/go-openapi/validate v0.19.2/go.mod h1:1tRCw7m3jtI8eNWEEliiAqUIcBztB2KDnRCRMUi7GTA=
github.com/go-openapi/validate v0.19.5/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4=
github.com/go-openapi/validate v0.19.8/go.mod h1:8DJv2CVJQ6kGNpFW6eV9N3JviE1C85nY1c2z52x1Gk4=
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-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/godbus/dbus/v5 v5.0.4/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/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
Expand Down
94 changes: 94 additions & 0 deletions internal/mocks/gitjob_cache_mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

110 changes: 110 additions & 0 deletions pkg/webhook/azuredevops/webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// File copied from https://github.com/go-playground/webhooks/blob/master/azuredevops/azuredevops.go
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think it could make sense to copy the corresponding test file over as well, just to be on the safe side. How do you see it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we would also need to copy all the testdata files.. https://github.com/go-playground/webhooks/blob/master/azuredevops/azuredevops_test.go#L63

I would avoid copying all those files. Should we leave it without the test or maybe we can just transfer my fork to the fleet org and use the fork instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's leave it without the test for now; in the meantime, I'll be watching your PR so that we can switch back to using upstream ASAP. Thanks for clarifying :)

// 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
}
19 changes: 19 additions & 0 deletions pkg/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import (
"strings"

"github.com/Masterminds/semver/v3"

goPlaygroundAzuredevops "github.com/go-playground/webhooks/v6/azuredevops"
gogsclient "github.com/gogits/go-gogs-client"
"github.com/gorilla/mux"
v1controller "github.com/rancher/gitjob/pkg/generated/controllers/gitjob.cattle.io/v1"
"github.com/rancher/gitjob/pkg/types"
"github.com/rancher/gitjob/pkg/webhook/azuredevops"
"github.com/rancher/steve/pkg/aggregation"
corev1controller "github.com/rancher/wrangler/pkg/generated/controllers/core/v1"
"github.com/sirupsen/logrus"
Expand All @@ -32,6 +35,8 @@ const (
bitbucketKey = "bitbucket"
bitbucketServerKey = "bitbucket-server"
gogsKey = "gogs"
azureUsername = "azure-username"
azurePassword = "azure-password"

branchRefPrefix = "refs/heads/"
tagRefPrefix = "refs/tags/"
Expand All @@ -47,6 +52,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 {
Expand Down Expand Up @@ -90,6 +96,10 @@ 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
}

Expand All @@ -110,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
Expand Down Expand Up @@ -169,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())
Expand Down
63 changes: 63 additions & 0 deletions pkg/webhook/webhook_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
package webhook

//go:generate mockgen --build_flags=--mod=mod -destination=../../internal/mocks/gitjob_cache_mock.go -package=mocks github.com/rancher/gitjob/pkg/generated/controllers/gitjob.cattle.io/v1 GitJobCache

import (
"bytes"
"net/http"
"testing"

"github.com/rancher/gitjob/pkg/webhook/azuredevops"

"github.com/golang/mock/gomock"
"github.com/rancher/gitjob/internal/mocks"
v1 "github.com/rancher/gitjob/pkg/apis/gitjob.cattle.io/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"

"gotest.tools/assert"
)

Expand Down Expand Up @@ -31,3 +43,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"
ctlr := gomock.NewController(t)
mockGitjob := mocks.NewMockGitJobController(ctlr)
mockGitjobCache := mocks.NewMockGitJobCache(ctlr)
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":"[email protected]","date":"2024-01-05T10:16:56Z"},"committer":{"name":"fleet","email":"[email protected]","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":"[email protected]","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"}`)
raulcabello marked this conversation as resolved.
Show resolved Hide resolved
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) {}
Loading