From d7d3b79420f107760a196903ff971df43f85e071 Mon Sep 17 00:00:00 2001 From: Ritam Mukherjee Date: Fri, 21 Nov 2025 13:41:15 +0000 Subject: [PATCH 1/2] Add support for Gerrit webhooks --- pkg/webhook/gerrit/gerrit.go | 116 ++++++++++++++++++++ pkg/webhook/gerrit/schema.go | 65 +++++++++++ pkg/webhook/parser.go | 22 ++++ pkg/webhook/parser_test.go | 203 +++++++++++++++++++++++++++++++++++ pkg/webhook/webhook.go | 11 ++ pkg/webhook/webhook_test.go | 127 ++++++++++++++++++++++ 6 files changed, 544 insertions(+) create mode 100644 pkg/webhook/gerrit/gerrit.go create mode 100644 pkg/webhook/gerrit/schema.go diff --git a/pkg/webhook/gerrit/gerrit.go b/pkg/webhook/gerrit/gerrit.go new file mode 100644 index 0000000000..bb688f7c7f --- /dev/null +++ b/pkg/webhook/gerrit/gerrit.go @@ -0,0 +1,116 @@ +package gerrit + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +var ( + ErrEventNotSpecifiedToParse = errors.New("no Event specified to parse") + ErrInvalidHTTPMethod = errors.New("invalid HTTP Method") + ErrMissingGerritEvent = errors.New("missing type field") + ErrMissingHubSignatureHeader = errors.New("missing X-Hub-Signature-256 Header") + ErrEventNotFound = errors.New("event not defined to be parsed") + ErrParsingPayload = errors.New("error parsing payload") + ErrHMACVerificationFailed = errors.New("HMAC verification failed") +) + +type Event string + +const ( + ChangeMergedEvent Event = "change-merged" +) + +// 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{} + +// GerritWebhook instance contains all methods needed to process events +type Webhook struct {} + +// New creates and returns a GerritWebhook 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 +} +func (hook *Webhook) Parse(r *http.Request, events ...Event) (interface{}, error) { + defer func() { + _, _ = io.Copy(io.Discard, r.Body) + _ = r.Body.Close() + }() + + if len(events) == 0 { + return nil, ErrEventNotSpecifiedToParse + } + if r.Method != http.MethodPost { + return nil, ErrInvalidHTTPMethod + } + + payload, err := io.ReadAll(r.Body) + if err != nil || len(payload) == 0 { + return nil, ErrParsingPayload + } + + var envelope Envelope + err = json.Unmarshal([]byte(payload), &envelope) + + if err != nil { + return nil, ErrParsingPayload + } + + event := envelope.Type + + if event == "" { + return nil, ErrMissingGerritEvent + } + + gerritEvent := Event(event) + + var found bool + for _, evt := range events { + if evt == gerritEvent { + found = true + break + } + } + + // event not defined to be parsed + if !found { + return nil, ErrEventNotFound + } + + switch gerritEvent { + case ChangeMergedEvent: + var pl ChangeMergedPayload + err = json.Unmarshal([]byte(payload), &pl) + return pl, err + default: + return nil, fmt.Errorf("unknown event %s", gerritEvent) + } + +} + +func ExtractRepoURL(payload ChangeMergedPayload) (string, error) { + // URL is in the format of https:///c//+/ + parts := strings.Split(payload.Change.URL, "/") + if len(parts) < 7 { + return "", fmt.Errorf("invalid URL %s", payload.Change.URL) + } + + url := fmt.Sprintf("%s//%s/%s", parts[0], parts[2], parts[4]) + return url, nil +} \ No newline at end of file diff --git a/pkg/webhook/gerrit/schema.go b/pkg/webhook/gerrit/schema.go new file mode 100644 index 0000000000..b6da034e9f --- /dev/null +++ b/pkg/webhook/gerrit/schema.go @@ -0,0 +1,65 @@ +package gerrit + +type Envelope struct { + Submitter User `json:"-"` + NewRev string `json:"-"` + PatchSet PatchSet `json:"-"` + Change Change `json:"-"` + Project Project `json:"-"` + RefName string `json:"-"` + ChangeKey ChangeKey `json:"-"` + Type string `json:"type"` + EventCreatedOn int64 `json:"-"` +} + +type ChangeMergedPayload struct { + Submitter User `json:"submitter"` + NewRev string `json:"newRev"` + PatchSet PatchSet `json:"patchSet"` + Change Change `json:"change"` + Project Project `json:"project"` + RefName string `json:"refName"` + ChangeKey ChangeKey `json:"changeKey"` + Type string `json:"type"` + EventCreatedOn int64 `json:"eventCreatedOn"` +} + +type User struct { + Name string `json:"name"` + Email string `json:"email"` + Username string `json:"username"` +} + +type PatchSet struct { + Number int64 `json:"number"` + Revision string `json:"revision"` + Parents []string `json:"parents"` + Ref string `json:"ref"` + Uploader User `json:"uploader"` + CreatedOn int64 `json:"createdOn"` + Author User `json:"author"` + Kind string `json:"kind"` + SizeInsertions int64 `json:"sizeInsertions"` + SizeDeletions int64 `json:"sizeDeletions"` +} + +type Change struct { + Project string `json:"project"` + Branch string `json:"branch"` + ID string `json:"id"` + Number int64 `json:"number"` + Subject string `json:"subject"` + Owner User `json:"owner"` + URL string `json:"url"` + CommitMessage string `json:"commitMessage"` + CreatedOn int64 `json:"createdOn"` + Status string `json:"status"` +} + +type Project struct { + Name string `json:"name"` +} + +type ChangeKey struct { + Key string `json:"key"` +} diff --git a/pkg/webhook/parser.go b/pkg/webhook/parser.go index 8827a391a4..8549d2f436 100644 --- a/pkg/webhook/parser.go +++ b/pkg/webhook/parser.go @@ -11,6 +11,8 @@ import ( "github.com/go-playground/webhooks/v6/gitlab" "github.com/go-playground/webhooks/v6/gogs" corev1 "k8s.io/api/core/v1" + + gerrit "github.com/rancher/fleet/pkg/webhook/gerrit" ) const ( @@ -38,6 +40,9 @@ func parseWebhook(r *http.Request, secret *corev1.Secret) (interface{}, error) { return parseBitbucketServer(r, secret) case r.Header.Get("X-Vss-Activityid") != "" || r.Header.Get("X-Vss-Subscriptionid") != "": return parseAzureDevops(r, secret) + // Gerrit does not provide any discernible headers to identify the event. So we need to put it at the end. + case r.Header.Get("x-origin-url") != "": + return parseGerrit(r, secret) } return nil, nil @@ -78,6 +83,23 @@ func parseGogs(r *http.Request, secret *corev1.Secret) (interface{}, error) { return hook.Parse(r, gogs.PushEvent) } +func parseGerrit(r *http.Request, secret *corev1.Secret) (interface{}, error) { + var hook *gerrit.Webhook + var err error + + if secret != nil { + // Gerrit does not support secrets. + } + + hook, err = gerrit.New() + + if err != nil { + return nil, err + } + + return hook.Parse(r, gerrit.ChangeMergedEvent) +} + func parseGithub(r *http.Request, secret *corev1.Secret) (interface{}, error) { var hook *github.Webhook var err error diff --git a/pkg/webhook/parser_test.go b/pkg/webhook/parser_test.go index 29b04d0749..7c2508929a 100644 --- a/pkg/webhook/parser_test.go +++ b/pkg/webhook/parser_test.go @@ -16,6 +16,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilruntime "k8s.io/apimachinery/pkg/util/runtime" "k8s.io/kubectl/pkg/scheme" + + gerrit "github.com/rancher/fleet/pkg/webhook/gerrit" ) func TestParseGogs(t *testing.T) { @@ -1272,3 +1274,204 @@ func TestParseAzureDevops(t *testing.T) { }) } } + +func TestParseGerrit(t *testing.T) { + utilruntime.Must(corev1.AddToScheme(scheme.Scheme)) + + tests := map[string]struct { + secretData map[string][]byte + body []byte + headers map[string]string + wantErr bool + wantErrMsg string + wantNilEvent bool + wantNewRev string + wantRefName string + wantType string + }{ + "valid-gerrit-change-merged-event": { + secretData: nil, + body: []byte(`{ + "submitter": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "newRev": "7681a9621922861f727d31fed11baa7dcbc18f89", + "patchSet": { + "number": 1, + "revision": "7681a9621922861f727d31fed11baa7dcbc18f89", + "parents": ["1a37d8b3045cdf9e45d5cb79849823609e27d6d0"], + "ref": "refs/changes/03/3/1", + "uploader": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "createdOn": 1763201771, + "author": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "kind": "REWORK", + "sizeInsertions": 10, + "sizeDeletions": 1 + }, + "change": { + "project": "test-repo", + "branch": "main", + "id": "I0bdc56353d26d6c113e3f57bd251af398580c698", + "number": 3, + "subject": "2nd commit", + "owner": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "url": "http://gerrit.example.com/c/test-repo/+/3", + "commitMessage": "2nd commit\n\nChange-Id: I0bdc56353d26d6c113e3f57bd251af398580c698\n", + "createdOn": 1763201771, + "status": "MERGED" + }, + "project": { + "name": "test-repo" + }, + "refName": "refs/heads/main", + "changeKey": { + "key": "I0bdc56353d26d6c113e3f57bd251af398580c698" + }, + "type": "change-merged", + "eventCreatedOn": 1763201787 + }`), + headers: map[string]string{ + "x-origin-url": "http://gerrit.example.com/", + }, + wantErr: false, + wantNilEvent: false, + wantNewRev: "7681a9621922861f727d31fed11baa7dcbc18f89", + wantRefName: "refs/heads/main", + wantType: "change-merged", + }, + "invalid-gerrit-event-type": { + secretData: nil, + body: []byte(`{ + "type": "unsupported-event" + }`), + headers: map[string]string{ + "x-origin-url": "http://gerrit.example.com/", + }, + wantErr: true, + wantErrMsg: "event not defined to be parsed", + wantNilEvent: true, + }, + "missing-gerrit-event-type": { + secretData: nil, + body: []byte(`{ + "newRev": "7681a9621922861f727d31fed11baa7dcbc18f89" + }`), + headers: map[string]string{ + "x-origin-url": "http://gerrit.example.com/", + }, + wantErr: true, + wantErrMsg: "missing type field", + wantNilEvent: true, + }, + "invalid-http-method": { + secretData: nil, + body: []byte(`{ + "type": "change-merged" + }`), + headers: map[string]string{ + "x-origin-url": "http://gerrit.example.com/", + }, + wantErr: true, + wantErrMsg: "invalid HTTP Method", + wantNilEvent: true, + }, + "empty-payload": { + secretData: nil, + body: []byte(``), + headers: map[string]string{ + "x-origin-url": "http://gerrit.example.com/", + }, + wantErr: true, + wantErrMsg: "error parsing payload", + wantNilEvent: true, + }, + "malformed-json": { + secretData: nil, + body: []byte(`{"invalid json`), + headers: map[string]string{ + "x-origin-url": "http://gerrit.example.com/", + }, + wantErr: true, + wantErrMsg: "error parsing payload", + wantNilEvent: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var secret *corev1.Secret + if tt.secretData != nil { + secret = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + Data: tt.secretData, + } + } + + httpMethod := http.MethodPost + if tt.wantErrMsg == "invalid HTTP Method" { + httpMethod = http.MethodGet + } + + req, err := http.NewRequest(httpMethod, "/", bytes.NewReader(tt.body)) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + got, err := parseGerrit(req, secret) + + if tt.wantErr { + assert.Error(t, err, tt.wantErrMsg) + return + } + + if err != nil { + t.Fatalf("parseGerrit() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantNilEvent { + if got != nil { + t.Fatalf("parseGerrit() got %v, want nil", got) + } + } else { + if got == nil { + t.Fatalf("parseGerrit() got nil, want not nil") + } + gotGerrit, ok := got.(gerrit.ChangeMergedPayload) + if !ok { + t.Fatalf("parseGerrit() got %T, want gerrit.ChangeMergedPayload", got) + } + + if gotGerrit.NewRev != tt.wantNewRev { + t.Fatalf("parseGerrit() got newRev %s, want %s", gotGerrit.NewRev, tt.wantNewRev) + } + if gotGerrit.RefName != tt.wantRefName { + t.Fatalf("parseGerrit() got refName %s, want %s", gotGerrit.RefName, tt.wantRefName) + } + if gotGerrit.Type != tt.wantType { + t.Fatalf("parseGerrit() got type %s, want %s", gotGerrit.Type, tt.wantType) + } + } + }) + } +} diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index 8c0a03a787..ab410d668b 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -22,6 +22,7 @@ import ( "github.com/gorilla/mux" fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1" + gerrit "github.com/rancher/fleet/pkg/webhook/gerrit" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" @@ -249,6 +250,7 @@ func getErrorCodeFromErr(err error) int { gitlab.ErrInvalidHTTPMethod, bitbucket.ErrInvalidHTTPMethod, bitbucketserver.ErrInvalidHTTPMethod, + gerrit.ErrInvalidHTTPMethod, azuredevops.ErrInvalidHTTPMethod: return http.StatusMethodNotAllowed @@ -274,6 +276,15 @@ func getBranchTagFromRef(ref string) (string, string) { func parsePayload(payload interface{}) (revision, branch, tag string, repoURLs []string) { // credit from https://github.com/argoproj/argo-cd/blob/97003caebcaafe1683e71934eb483a88026a4c33/util/webhook/webhook.go#L84-L87 switch t := payload.(type) { + case gerrit.ChangeMergedPayload: + branch, tag = getBranchTagFromRef(t.RefName) + revision = t.NewRev + repoURL, err := gerrit.ExtractRepoURL(t) + if err != nil { + fmt.Println("Error extracting repo URL from gerrit", err) + break + } + repoURLs = append(repoURLs, repoURL) case github.PushPayload: branch, tag = getBranchTagFromRef(t.Ref) revision = t.After diff --git a/pkg/webhook/webhook_test.go b/pkg/webhook/webhook_test.go index 9f6b4d8762..d8cb56915f 100644 --- a/pkg/webhook/webhook_test.go +++ b/pkg/webhook/webhook_test.go @@ -24,6 +24,7 @@ import ( "github.com/go-playground/webhooks/v6/gogs" "github.com/rancher/fleet/internal/mocks" v1alpha1 "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1" + gerrit "github.com/rancher/fleet/pkg/webhook/gerrit" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -782,3 +783,129 @@ func TestErrorReadingRequest(t *testing.T) { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) } } + +func TestGerritWebhook(t *testing.T) { + const commit = "7681a9621922861f727d31fed11baa7dcbc18f89" + const repoURL = "https://gerrit.example.com/test-repo" + gitRepo := &v1alpha1.GitRepo{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: v1alpha1.GitRepoSpec{ + Repo: repoURL, + Branch: "main", + }, + } + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(v1alpha1.AddToScheme(scheme)) + + client := cfake.NewClientBuilder().WithScheme(scheme).WithRuntimeObjects(gitRepo).WithStatusSubresource(gitRepo).Build() + w := &Webhook{client: client} + jsonBody := []byte(`{ + "submitter": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "newRev": "` + commit + `", + "patchSet": { + "number": 1, + "revision": "` + commit + `", + "parents": ["1a37d8b3045cdf9e45d5cb79849823609e27d6d0"], + "ref": "refs/changes/03/3/1", + "uploader": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "createdOn": 1763201771, + "author": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "kind": "REWORK", + "sizeInsertions": 10, + "sizeDeletions": 1 + }, + "change": { + "project": "test-repo", + "branch": "main", + "id": "I0bdc56353d26d6c113e3f57bd251af398580c698", + "number": 3, + "subject": "2nd commit", + "owner": { + "name": "Administrator", + "email": "admin@example.com", + "username": "admin" + }, + "url": "http://gerrit.example.com/c/test-repo/+/3", + "commitMessage": "2nd commit\n\nChange-Id: I0bdc56353d26d6c113e3f57bd251af398580c698\n", + "createdOn": 1763201771, + "status": "MERGED" + }, + "project": { + "name": "test-repo" + }, + "refName": "refs/heads/main", + "changeKey": { + "key": "I0bdc56353d26d6c113e3f57bd251af398580c698" + }, + "type": "change-merged", + "eventCreatedOn": 1763201787 + }`) + 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-origin-url", "http://gerrit.example.com/") + req.Header = h + + w.ServeHTTP(&responseWriter{}, req) + + updatedGitRepo := &v1alpha1.GitRepo{} + err = client.Get(context.TODO(), types.NamespacedName{Name: gitRepo.Name, Namespace: gitRepo.Namespace}, updatedGitRepo) + if err != nil { + t.Errorf("unexpected err %v", err) + } + if updatedGitRepo.Status.WebhookCommit != commit { + t.Errorf("expected webhook commit %v, but got %v", commit, updatedGitRepo.Status.WebhookCommit) + } +} + +func TestAuthErrorCodesGerrit(t *testing.T) { + tests := map[string]struct { + err error + expectedErrorCode int + }{ + "gerrit-invalid-http-method": { + err: gerrit.ErrInvalidHTTPMethod, + expectedErrorCode: http.StatusMethodNotAllowed, + }, + "gerrit-event-not-found": { + err: gerrit.ErrEventNotFound, + expectedErrorCode: http.StatusInternalServerError, + }, + "gerrit-missing-event": { + err: gerrit.ErrMissingGerritEvent, + expectedErrorCode: http.StatusInternalServerError, + }, + "gerrit-parsing-payload": { + err: gerrit.ErrParsingPayload, + expectedErrorCode: http.StatusInternalServerError, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + errCode := getErrorCodeFromErr(test.err) + + if errCode != test.expectedErrorCode { + t.Errorf("expected error code does not match. Got %d, expected %d", errCode, test.expectedErrorCode) + } + }) + } +} From 6148075f548f0f687c68f728a4d7eeb01f341816 Mon Sep 17 00:00:00 2001 From: Ritam Mukherjee Date: Fri, 21 Nov 2025 13:48:07 +0000 Subject: [PATCH 2/2] adds error code for gerrit --- pkg/webhook/webhook.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/webhook/webhook.go b/pkg/webhook/webhook.go index aa6ee9d7f5..df7f1e7440 100644 --- a/pkg/webhook/webhook.go +++ b/pkg/webhook/webhook.go @@ -251,8 +251,9 @@ func getErrorCodeFromErr(err error) int { errors.Is(err, gitlab.ErrInvalidHTTPMethod), errors.Is(err, bitbucket.ErrInvalidHTTPMethod), errors.Is(err, bitbucketserver.ErrInvalidHTTPMethod), - errors.Is(err, azuredevops.ErrInvalidHTTPMethod): - + errors.Is(err, azuredevops.ErrInvalidHTTPMethod), + errors.Is(err, gerrit.ErrInvalidHTTPMethod): + return http.StatusMethodNotAllowed } return http.StatusInternalServerError