diff --git a/go.mod b/go.mod index 83385c4b..6db62e0d 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/Masterminds/semver/v3 v3.2.1 github.com/go-git/go-git/v5 v5.11.0 github.com/go-logr/logr v1.4.1 + github.com/go-playground/webhooks/v6 v6.3.0 github.com/gogits/go-gogs-client v0.0.0-20210131175652-1d7215cd8d85 github.com/google/go-cmp v0.6.0 github.com/gorilla/mux v1.8.1 diff --git a/go.sum b/go.sum index 5ae58320..1785171f 100644 --- a/go.sum +++ b/go.sum @@ -98,10 +98,13 @@ github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2Kv github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 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-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/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/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 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 21d3b55a..f755de4e 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -8,6 +8,9 @@ import ( "regexp" "strings" + goPlaygroundAzuredevops "github.com/go-playground/webhooks/v6/azuredevops" + "github.com/rancher/gitjob/pkg/webhook/azuredevops" + "github.com/go-logr/logr" ctrl "sigs.k8s.io/controller-runtime" @@ -39,6 +42,8 @@ const ( bitbucketKey = "bitbucket" bitbucketServerKey = "bitbucket-server" gogsKey = "gogs" + azureUsername = "azure-username" + azurePassword = "azure-password" branchRefPrefix = "refs/heads/" tagRefPrefix = "refs/tags/" @@ -53,6 +58,7 @@ type Webhook struct { bitbucketServer *bitbucketserver.Webhook gogs *gogs.Webhook log logr.Logger + azureDevops *azuredevops.Webhook } func New(namespace string, client client.Client) (*Webhook, error) { @@ -92,6 +98,10 @@ func (w *Webhook) initGitProviders() error { if err != nil { return err } + w.azureDevops, err = azuredevops.New() + if err != nil { + return err + } return nil } @@ -131,6 +141,11 @@ func (w *Webhook) onSecretChange(obj interface{}) error { return err } w.gogs = gogs + azureDevops, err := azuredevops.New(azuredevops.Options.BasicAuth(string(secret.Data[azureUsername]), string(secret.Data[azurePassword]))) + if err != nil { + return err + } + w.azureDevops = azureDevops return nil } @@ -153,6 +168,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 @@ -212,6 +229,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 + } } var gitJobList v1.GitJobList diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go index be7df69e..dd703175 100644 --- a/pkg/webhook/webhook_test.go +++ b/pkg/webhook/webhook_test.go @@ -1,6 +1,18 @@ package webhook import ( + "bytes" + "context" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + + v1 "github.com/rancher/gitjob/pkg/apis/gitjob.cattle.io/v1" + "github.com/rancher/gitjob/pkg/webhook/azuredevops" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + cfake "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "net/http" "testing" "gotest.tools/assert" @@ -31,3 +43,58 @@ 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" + gitjob := &v1.GitJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: v1.GitJobSpec{ + Git: v1.GitInfo{ + Repo: repoURL, + Branch: "main", + }, + }, + } + scheme := runtime.NewScheme() + err := v1.AddToScheme(scheme) + if err != nil { + t.Errorf("unexpected error %v", err) + } + client := cfake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gitjob).WithStatusSubresource(gitjob).Build() + w := &Webhook{client: client} + 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 + + w.ServeHTTP(&responseWriter{}, req) + + updatedGitJob := &v1.GitJob{} + err = client.Get(context.TODO(), types.NamespacedName{Name: gitjob.Name, Namespace: gitjob.Namespace}, updatedGitJob) + if err != nil { + t.Errorf("unexpected err %v", err) + } + if updatedGitJob.Status.Commit != commit { + t.Errorf("expected commit %v, but got %v", commit, updatedGitJob.Status.Commit) + } +} + +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) {}