diff --git a/api/v1beta2/provider_types.go b/api/v1beta2/provider_types.go index b5fd94835..069b2d4b2 100644 --- a/api/v1beta2/provider_types.go +++ b/api/v1beta2/provider_types.go @@ -24,38 +24,39 @@ import ( ) const ( - ProviderKind string = "Provider" - GenericProvider string = "generic" - GenericHMACProvider string = "generic-hmac" - SlackProvider string = "slack" - GrafanaProvider string = "grafana" - DiscordProvider string = "discord" - MSTeamsProvider string = "msteams" - RocketProvider string = "rocket" - GitHubDispatchProvider string = "githubdispatch" - GitHubProvider string = "github" - GitLabProvider string = "gitlab" - GiteaProvider string = "gitea" - BitbucketProvider string = "bitbucket" - AzureDevOpsProvider string = "azuredevops" - GoogleChatProvider string = "googlechat" - GooglePubSubProvider string = "googlepubsub" - WebexProvider string = "webex" - SentryProvider string = "sentry" - AzureEventHubProvider string = "azureeventhub" - TelegramProvider string = "telegram" - LarkProvider string = "lark" - Matrix string = "matrix" - OpsgenieProvider string = "opsgenie" - AlertManagerProvider string = "alertmanager" - PagerDutyProvider string = "pagerduty" - DataDogProvider string = "datadog" + ProviderKind string = "Provider" + GenericProvider string = "generic" + GenericHMACProvider string = "generic-hmac" + SlackProvider string = "slack" + GrafanaProvider string = "grafana" + DiscordProvider string = "discord" + MSTeamsProvider string = "msteams" + RocketProvider string = "rocket" + GitHubDispatchProvider string = "githubdispatch" + GitHubProvider string = "github" + GitLabProvider string = "gitlab" + GiteaProvider string = "gitea" + BitbucketServerProvider string = "bitbucketserver" + BitbucketProvider string = "bitbucket" + AzureDevOpsProvider string = "azuredevops" + GoogleChatProvider string = "googlechat" + GooglePubSubProvider string = "googlepubsub" + WebexProvider string = "webex" + SentryProvider string = "sentry" + AzureEventHubProvider string = "azureeventhub" + TelegramProvider string = "telegram" + LarkProvider string = "lark" + Matrix string = "matrix" + OpsgenieProvider string = "opsgenie" + AlertManagerProvider string = "alertmanager" + PagerDutyProvider string = "pagerduty" + DataDogProvider string = "datadog" ) // ProviderSpec defines the desired state of the Provider. type ProviderSpec struct { // Type specifies which Provider implementation to use. - // +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;bitbucket;azuredevops;googlechat;googlepubsub;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;pagerduty;datadog + // +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;gitea;bitbucketserver;bitbucket;azuredevops;googlechat;googlepubsub;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch;pagerduty;datadog // +required Type string `json:"type"` diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml index 88f1c99e6..032076c16 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -291,6 +291,7 @@ spec: - github - gitlab - gitea + - bitbucketserver - bitbucket - azuredevops - googlechat diff --git a/docs/spec/v1beta2/providers.md b/docs/spec/v1beta2/providers.md index 644ce34e5..cadae4bf5 100644 --- a/docs/spec/v1beta2/providers.md +++ b/docs/spec/v1beta2/providers.md @@ -129,13 +129,14 @@ The supported alerting providers are: The supported providers for [Git commit status updates](#git-commit-status-updates) are: -| Provider | Type | -|-------------------------------|---------------| -| [Azure DevOps](#azure-devops) | `azuredevops` | -| [Bitbucket](#bitbucket) | `bitbucket` | -| [GitHub](#github) | `github` | -| [GitLab](#gitlab) | `gitlab` | -| [Gitea](#gitea) | `gitea` | +| Provider | Type | +| ------------------------------------------------| ----------------- | +| [Azure DevOps](#azure-devops) | `azuredevops` | +| [Bitbucket](#bitbucket) | `bitbucket` | +| [BitbucketServer](#bitbucket-serverdata-center) | `bitbucketserver` | +| [GitHub](#github) | `github` | +| [GitLab](#gitlab) | `gitlab` | +| [Gitea](#gitea) | `gitea` | #### Alerting @@ -1514,6 +1515,30 @@ You can create the secret with `kubectl` like this: kubectl create secret generic bitbucket-token --from-literal=token=: ``` +#### BitBucket Server/Data Center + +When `.spec.type` is set to `bitbucketserver`, the following auth methods are available: + +- Basic Authentication (username/password) +- [HTTP access tokens](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) + +For Basic Authentication, the referenced secret must contain a `password` field. The `username` field can either come from the [`.spec.username` field of the Provider](https://fluxcd.io/flux/components/notification/providers/#username) or can be defined in the referenced secret. + +You can create the secret with `kubectl` like this: + +```shell +kubectl create secret generic bb-server-username-password --from-literal=username= --from-literal=password= +``` + +For HTTP access tokens, the secret can be created with `kubectl` like this: + +```shell +kubectl create secret generic bb-server-token --from-literal=token= +``` + +The HTTP access token must have `Repositories (Read/Write)` permission for +the repository specified in `.spec.address`. + #### Azure DevOps When `.spec.type` is set to `azuredevops`, the referenced secret must contain a key called `token` with the value set to a diff --git a/go.mod b/go.mod index 23f0a3585..d97e512cb 100644 --- a/go.mod +++ b/go.mod @@ -142,6 +142,7 @@ require ( golang.org/x/sys v0.13.0 // indirect golang.org/x/term v0.13.0 // indirect golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.13.0 // indirect gomodules.xyz/jsonpatch/v2 v2.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20230803162519-f966b187b2e5 // indirect diff --git a/go.sum b/go.sum index fa7178a6c..c52504729 100644 --- a/go.sum +++ b/go.sum @@ -1514,7 +1514,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.9.3 h1:Gn1I8+64MsuTb/HpH+LmQtNas23LhUVr3rYZ0eKuaMM= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/notifier/bitbucketserver.go b/internal/notifier/bitbucketserver.go new file mode 100644 index 000000000..5ccf1116b --- /dev/null +++ b/internal/notifier/bitbucketserver.go @@ -0,0 +1,294 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notifier + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/fluxcd/pkg/apis/meta" + "github.com/hashicorp/go-retryablehttp" +) + +// BitbucketServer is a notifier for BitBucket Server and Data Center. +type BitbucketServer struct { + ProjectKey string + RepositorySlug string + ProviderUID string + ProviderAddress string + Host string + Username string + Password string + Token string + Client *retryablehttp.Client +} + +const ( + bbServerEndPointTmpl = "/rest/api/latest/projects/%[1]s/repos/%[2]s/commits/%[3]s/builds" + bbServerGetBuildStatusQueryString = "key" +) + +type bbServerBuildStatus struct { + Name string `json:"name,omitempty"` + Key string `json:"key,omitempty"` + Parent string `json:"parent,omitempty"` + State string `json:"state,omitempty"` + Ref string `json:"ref,omitempty"` + BuildNumber string `json:"buildNumber,omitempty"` + Description string `json:"description,omitempty"` + Duration int64 `json:"duration,omitempty"` + UpdatedDate int64 `json:"updatedDate,omitempty"` + CreatedDate int64 `json:"createdDate,omitempty"` + Url string `json:"url,omitempty"` +} + +type bbServerBuildStatusSetRequest struct { + BuildNumber string `json:"buildNumber,omitempty"` + Description string `json:"description,omitempty"` + Duration int64 `json:"duration,omitempty"` + Key string `json:"key"` + LastUpdated int64 `json:"lastUpdated,omitempty"` + Name string `json:"name,omitempty"` + Parent string `json:"parent,omitempty"` + Ref string `json:"ref,omitempty"` + State string `json:"state"` + Url string `json:"url"` +} + +// NewBitbucketServer creates and returns a new BitbucketServer notifier. +func NewBitbucketServer(providerUID string, addr string, token string, certPool *x509.CertPool, username string, password string) (*BitbucketServer, error) { + hst, id, err := parseBitbucketServerGitAddress(addr) + if err != nil { + return nil, err + } + + comp := strings.Split(id, "/") + if len(comp) != 2 { + return nil, fmt.Errorf("invalid repository id %q", id) + } + projectkey := comp[0] + reposlug := comp[1] + + httpClient := retryablehttp.NewClient() + if certPool != nil { + httpClient.HTTPClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + }, + } + } + + httpClient.HTTPClient.Timeout = 15 * time.Second + httpClient.RetryWaitMin = 2 * time.Second + httpClient.RetryWaitMax = 30 * time.Second + httpClient.RetryMax = 4 + httpClient.Logger = nil + + if len(token) == 0 && (len(username) == 0 || len(password) == 0) { + return nil, errors.New("invalid credentials, expected to be one of username/password or API Token") + } + + return &BitbucketServer{ + ProjectKey: projectkey, + RepositorySlug: reposlug, + ProviderUID: providerUID, + Host: hst, + ProviderAddress: addr, + Token: token, + Username: username, + Password: password, + Client: httpClient, + }, nil +} + +// Post Bitbucket Server build status +func (b BitbucketServer) Post(ctx context.Context, event eventv1.Event) error { + // Skip progressing events + if event.HasReason(meta.ProgressingReason) { + return nil + } + revString, ok := event.Metadata[eventv1.MetaRevisionKey] + if !ok { + return errors.New("missing revision metadata") + } + rev, err := parseRevision(revString) + if err != nil { + return fmt.Errorf("could not parse revision: %w", err) + } + state, err := b.state(event.Severity) + if err != nil { + return fmt.Errorf("couldn't convert to bitbucket server state: %w", err) + } + + name, desc := formatNameAndDescription(event) + name = name + " [" + desc + "]" //Bitbucket server displays this data on browser. Thus adding description here. + id := generateCommitStatusID(b.ProviderUID, event) + // key has a limitation of 40 characters in bitbucket api + key := sha1String(id) + + u := b.Host + b.createApiPath(rev) + dupe, err := b.duplicateBitbucketServerStatus(ctx, rev, state, name, desc, id, key, u) + if err != nil { + return fmt.Errorf("could not get existing commit status: %w", err) + } + + if !dupe { + _, err = b.postBuildStatus(ctx, rev, state, name, desc, id, key, u) + if err != nil { + return fmt.Errorf("could not post build status: %w", err) + } + } + + return nil +} + +func (b BitbucketServer) state(severity string) (string, error) { + switch severity { + case eventv1.EventSeverityInfo: + return "SUCCESSFUL", nil + case eventv1.EventSeverityError: + return "FAILED", nil + default: + return "", errors.New("bitbucket server state generated on info or error events only") + } +} + +func (b BitbucketServer) duplicateBitbucketServerStatus(ctx context.Context, rev, state, name, desc, id, key, u string) (bool, error) { + // Prepare request object + req, err := b.prepareCommonRequest(ctx, u, nil, http.MethodGet, key, rev) + if err != nil { + return false, fmt.Errorf("could not check duplicate commit status: %w", err) + } + + // Set query string + q := url.Values{} + q.Add(bbServerGetBuildStatusQueryString, key) + req.URL.RawQuery = q.Encode() + + // Make a GET call + d, err := b.Client.Do(req) + if err != nil && d.StatusCode != http.StatusNotFound { + return false, fmt.Errorf("failed api call to check duplicate commit status: %w", err) + } + if isError(d) && d.StatusCode != http.StatusNotFound { + defer d.Body.Close() + return false, fmt.Errorf("failed api call to check duplicate commit status: %d - %s", d.StatusCode, http.StatusText(d.StatusCode)) + } + defer d.Body.Close() + + if d.StatusCode == http.StatusOK { + bd, err := io.ReadAll(d.Body) + if err != nil { + return false, fmt.Errorf("could not read response body for duplicate commit status: %w", err) + } + var existingCommitStatus bbServerBuildStatus + err = json.Unmarshal(bd, &existingCommitStatus) + if err != nil { + return false, fmt.Errorf("could not unmarshal json response body for duplicate commit status: %w", err) + } + // Do not post duplicate build status + if existingCommitStatus.Key == key && existingCommitStatus.State == state && existingCommitStatus.Description == desc && existingCommitStatus.Name == name { + return true, nil + } + } + return false, nil +} + +func (b BitbucketServer) postBuildStatus(ctx context.Context, rev, state, name, desc, id, key, url string) (*http.Response, error) { + //Prepare json body + j := &bbServerBuildStatusSetRequest{ + Key: key, + State: state, + Url: b.ProviderAddress, + Description: desc, + Name: name, + } + p := new(bytes.Buffer) + err := json.NewEncoder(p).Encode(j) + if err != nil { + return nil, fmt.Errorf("failed preparing request for post build commit status, could not encode request body to json: %w", err) + } + + //Prepare request + req, err := b.prepareCommonRequest(ctx, url, p, http.MethodPost, key, rev) + if err != nil { + return nil, fmt.Errorf("failed preparing request for post build commit status: %w", err) + } + + // Add Content type header + req.Header.Add("Content-Type", "application/json") + + // Make a POST call + resp, err := b.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("could not post build commit status: %w", err) + } + // Note: A non-2xx status code doesn't cause an error: https://pkg.go.dev/net/http#Client.Do + if isError(resp) { + defer resp.Body.Close() + return nil, fmt.Errorf("could not post build commit status: %d - %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + defer resp.Body.Close() + return resp, nil +} + +func (b BitbucketServer) createApiPath(rev string) string { + return fmt.Sprintf(bbServerEndPointTmpl, b.ProjectKey, b.RepositorySlug, rev) +} + +func parseBitbucketServerGitAddress(s string) (string, string, error) { + host, id, err := parseGitAddress(s) + if err != nil { + return "", "", fmt.Errorf("could not parse git address: %w", err) + } + //Remove "scm/" --> https://community.atlassian.com/t5/Bitbucket-questions/remote-url-in-Bitbucket-server-what-does-scm-represent-is-it/qaq-p/2060987 + id = strings.TrimPrefix(id, "scm/") + return host, id, nil +} + +func (b BitbucketServer) prepareCommonRequest(ctx context.Context, path string, body io.Reader, method string, key, rev string) (*retryablehttp.Request, error) { + req, err := retryablehttp.NewRequestWithContext(ctx, method, path, body) + if err != nil { + return nil, fmt.Errorf("could not prepare request: %w", err) + } + + if b.Token != "" { + req.Header.Set("Authorization", "Bearer "+b.Token) + } else { + req.Header.Add("Authorization", "Basic "+basicAuth(b.Username, b.Password)) + } + req.Header.Add("x-atlassian-token", "no-check") + req.Header.Add("x-requested-with", "XMLHttpRequest") + + return req, nil +} + +// isError method returns true if HTTP status `code >= 400` otherwise false. +func isError(r *http.Response) bool { + return r.StatusCode > 399 +} diff --git a/internal/notifier/bitbucketserver_test.go b/internal/notifier/bitbucketserver_test.go new file mode 100644 index 000000000..fa8207e18 --- /dev/null +++ b/internal/notifier/bitbucketserver_test.go @@ -0,0 +1,378 @@ +/* +Copyright 2023 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package notifier + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "testing" + + "net/http" + "net/http/httptest" + + eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestNewBitbucketServerBasic(t *testing.T) { + b, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar.git", "", nil, "dummyuser", "testpassword") + assert.Nil(t, err) + assert.Equal(t, b.Username, "dummyuser") + assert.Equal(t, b.Password, "testpassword") +} + +func TestNewBitbucketServerToken(t *testing.T) { + b, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar.git", "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP", nil, "", "") + assert.Nil(t, err) + assert.Equal(t, b.Token, "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP") +} + +func TestNewBitbucketServerInvalidCreds(t *testing.T) { + _, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar.git", "", nil, "", "") + assert.NotNil(t, err) + assert.Equal(t, err.Error(), "invalid credentials, expected to be one of username/password or API Token") +} + +func TestNewBitbucketServerInvalidRepo(t *testing.T) { + _, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar/invalid.git", "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP", nil, "", "") + assert.NotNil(t, err) + assert.Equal(t, err.Error(), "invalid repository id \"projectfoo/repobar/invalid\"") +} + +func TestPostBitbucketServerMissingRevision(t *testing.T) { + b, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar.git", "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP", nil, "", "") + assert.Nil(t, err) + + //Validate missing revision + err = b.Post(context.TODO(), generateTestEventKustomization("info", map[string]string{ + "dummybadrevision": "bad", + })) + assert.NotNil(t, err) + assert.Equal(t, err.Error(), "missing revision metadata") +} + +func TestPostBitbucketServerBadCommitHash(t *testing.T) { + b, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar.git", "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP", nil, "", "") + assert.Nil(t, err) + + //Validate extract commit hash + err = b.Post(context.TODO(), generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "badhash", + })) + assert.NotNil(t, err) + assert.Equal(t, err.Error(), "could not parse revision: failed to extract commit hash from 'badhash' revision") + +} + +func TestPostBitbucketServerBadBitbucketState(t *testing.T) { + b, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "https://example.com:7990/scm/projectfoo/repobar.git", "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP", nil, "", "") + assert.Nil(t, err) + + //Validate conversion to bitbucket state + err = b.Post(context.TODO(), generateTestEventKustomization("badserveritystate", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + })) + assert.NotNil(t, err) + assert.Equal(t, err.Error(), "couldn't convert to bitbucket server state: bitbucket server state generated on info or error events only") + +} + +func generateTestEventKustomization(severity string, metadata map[string]string) eventv1.Event { + return eventv1.Event{ + InvolvedObject: corev1.ObjectReference{ + Kind: "Kustomization", + Namespace: "flux-system", + Name: "hello-world", + }, + Severity: severity, + Timestamp: metav1.Now(), + Message: "message", + Reason: "reason", + Metadata: metadata, + ReportingController: "kustomize-controller", + ReportingInstance: "kustomize-controller-xyz", + } +} + +func TestBitBucketServerPostValidateRequest(t *testing.T) { + tests := []struct { + name string + errorString string + testFailReason string + headers map[string]string + username string + password string + token string + event eventv1.Event + provideruid string + key string + }{ + { + name: "Validate Token Auth ", + token: "goodtoken", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Bearer goodtoken", + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + { + name: "Validate Basic Auth and Post State=Successful", + username: "hello", + password: "password", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("hello"+":"+"password")), + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + { + name: "Validate Post State=Failed", + username: "hello", + password: "password", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("hello"+":"+"password")), + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + { + name: "Fail if bad json response in existing commit status", + testFailReason: "badjson", + errorString: "could not get existing commit status: could not unmarshal json response body for duplicate commit status: unexpected end of JSON input", + username: "hello", + password: "password", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("hello"+":"+"password")), + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + { + name: "Fail if status code is non-200 in existing commit status", + testFailReason: "badstatuscode", + errorString: "could not get existing commit status: failed api call to check duplicate commit status: 400 - Bad Request", + username: "hello", + password: "password", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("hello"+":"+"password")), + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + { + name: "Bad post- Unauthorized", + testFailReason: "badpost", + errorString: "could not post build status: could not post build commit status: 401 - Unauthorized", + username: "hello", + password: "password", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("hello"+":"+"password")), + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("error", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + { + name: "Validate duplicate commit status successful match", + username: "hello", + password: "password", + provideruid: "0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", + headers: map[string]string{ + "Authorization": "Basic " + base64.StdEncoding.EncodeToString([]byte("hello"+":"+"password")), + "x-atlassian-token": "no-check", + "x-requested-with": "XMLHttpRequest", + }, + event: generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }), + key: sha1String(generateCommitStatusID("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", generateTestEventKustomization("info", map[string]string{ + eventv1.MetaRevisionKey: "main@sha1:5394cb7f48332b2de7c17dd8b8384bbc84b7e738", + }))), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + + // Validate Headers + for key, value := range tt.headers { + require.Equal(t, value, r.Header.Get(key)) + } + + // Validate URI + require.Equal(t, r.URL.Path, "/rest/api/latest/projects/projectfoo/repos/repobar/commits/5394cb7f48332b2de7c17dd8b8384bbc84b7e738/builds") + + // Validate Get Build Status call + if r.Method == http.MethodGet { + + //Validate that this GET request has a query string with "key" as the query paraneter + require.Equal(t, r.URL.Query().Get(bbServerGetBuildStatusQueryString), tt.key) + + // Validate that this GET request has no body + require.Equal(t, http.NoBody, r.Body) + + if tt.name == "Validate duplicate commit status successful match" { + w.WriteHeader(http.StatusOK) + w.Header().Add("Content-Type", "application/json") + name, desc := formatNameAndDescription(tt.event) + name = name + " [" + desc + "]" + jsondata, _ := json.Marshal(&bbServerBuildStatus{ + Name: name, + Description: desc, + Key: sha1String(generateCommitStatusID(tt.provideruid, tt.event)), + State: "SUCCESSFUL", + Url: "https://example.com:7990/scm/projectfoo/repobar.git", + }) + w.Write(jsondata) + } + if tt.testFailReason == "badstatuscode" { + w.WriteHeader(http.StatusBadRequest) + } else if tt.testFailReason == "badjson" { + w.WriteHeader(http.StatusOK) + w.Header().Add("Content-Type", "application/json") + //Do nothing here and an empty/null body will be returned + } else { + if tt.name != "Validate duplicate commit status successful match" { + w.WriteHeader(http.StatusOK) + w.Header().Add("Content-Type", "application/json") + w.Write([]byte(`{ + "description": "reconciliation succeeded", + "key": "TEST2", + "state": "SUCCESSFUL", + "name": "kustomization/helloworld-yaml-2-bitbucket-server [reconciliation succeeded]", + "url": "https://example.com:7990/scm/projectfoo/repobar.git" + }`)) + } + } + } + + // Validate Post BuildStatus call + if r.Method == http.MethodPost { + + // Validate that this POST request has no query string + require.Equal(t, len(r.URL.Query()), 0) + + // Validate that this POST request has Content-Type: application/json header + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + + // Read json body of the request + b, err := io.ReadAll(r.Body) + require.NoError(t, err) + + // Parse json request into Payload Request body struct + var payload bbServerBuildStatusSetRequest + err = json.Unmarshal(b, &payload) + require.NoError(t, err) + + // Validate Key + require.Equal(t, payload.Key, tt.key) + + // Validate that state can be only SUCCESSFUL or FAILED + if payload.State != "SUCCESSFUL" && payload.State != "FAILED" { + require.Fail(t, "Invalid state") + } + + // If severity of event is info, state should be SUCCESSFUL + if tt.event.Severity == "info" { + require.Equal(t, "SUCCESSFUL", payload.State) + } + + // If severity of event is error, state should be FAILED + if tt.event.Severity == "error" { + require.Equal(t, "FAILED", payload.State) + } + + // Validate description + require.Equal(t, "reason", payload.Description) + + // Validate name(with description appended) + require.Equal(t, "kustomization/hello-world"+" ["+payload.Description+"]", payload.Name) + + require.Contains(t, payload.Url, "/scm/projectfoo/repobar.git") + + if tt.testFailReason == "badpost" { + w.WriteHeader(http.StatusUnauthorized) + } + + // Sending a bad response here + // This proves that the duplicate commit status is never posted + if tt.name == "Validate duplicate commit status successful match" { + w.WriteHeader(http.StatusUnauthorized) + } + } + })) + defer ts.Close() + c, err := NewBitbucketServer(tt.provideruid, ts.URL+"/scm/projectfoo/repobar.git", tt.token, nil, tt.username, tt.password) + require.NoError(t, err) + err = c.Post(context.TODO(), tt.event) + if tt.testFailReason == "" { + require.NoError(t, err) + } else { + assert.NotNil(t, err) + assert.Equal(t, err.Error(), tt.errorString) + } + }) + } +} diff --git a/internal/notifier/factory.go b/internal/notifier/factory.go index 62458b68f..14305b782 100644 --- a/internal/notifier/factory.go +++ b/internal/notifier/factory.go @@ -85,6 +85,8 @@ func (f Factory) Notifier(provider string) (Interface, error) { n, err = NewGitLab(f.ProviderUID, f.URL, f.Token, f.CertPool) case apiv1.GiteaProvider: n, err = NewGitea(f.ProviderUID, f.URL, f.Token, f.CertPool) + case apiv1.BitbucketServerProvider: + n, err = NewBitbucketServer(f.ProviderUID, f.URL, f.Token, f.CertPool, f.Username, f.Password) case apiv1.BitbucketProvider: n, err = NewBitbucket(f.ProviderUID, f.URL, f.Token, f.CertPool) case apiv1.AzureDevOpsProvider: