From 2bc92e929c798fac8176ee44297c26940edf8f46 Mon Sep 17 00:00:00 2001 From: gdasson Date: Mon, 30 Oct 2023 23:39:06 -0500 Subject: [PATCH] Adding bitbucketserver provider for git commit status Signed-off-by: gdasson --- .github/workflows/backport.yaml | 2 +- .github/workflows/release.yml | 2 +- Makefile | 3 +- api/v1beta1/provider_types.go | 45 ++- ...ification.toolkit.fluxcd.io_providers.yaml | 1 - docs/spec/v1beta1/providers.md | 166 +++------ docs/spec/v1beta2/providers.md | 107 +++--- go.mod | 5 +- go.sum | 9 +- internal/notifier/bitbucketserver.go | 259 +++++++++---- internal/notifier/bitbucketserver_test.go | 346 +++++++++++++++++- internal/notifier/util.go | 17 - 12 files changed, 657 insertions(+), 305 deletions(-) diff --git a/.github/workflows/backport.yaml b/.github/workflows/backport.yaml index 02032cb16..e3fdd5d39 100644 --- a/.github/workflows/backport.yaml +++ b/.github/workflows/backport.yaml @@ -17,7 +17,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - name: Create backport PRs - uses: korthout/backport-action@408fae11ed190c2f91bf15d15af01b8f8b45709b # v2.0.0 + uses: korthout/backport-action@08bafb375e6e9a9a2b53a744b987e5d81a133191 # v2.1.1 # xref: https://github.com/korthout/backport-action#inputs with: # Use token to allow workflows to be triggered for the created PR diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 50b2bfec3..33615f473 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,7 +79,7 @@ jobs: platforms: linux/amd64,linux/arm/v7,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - - uses: sigstore/cosign-installer@11086d25041f77fe8fe7b9ea4e48e3b9192b8f19 # v3.1.2 + - uses: sigstore/cosign-installer@1fc5bd396d372bee37d608f955b336615edf79c8 # v3.2.0 - name: Sign images env: COSIGN_EXPERIMENTAL: 1 diff --git a/Makefile b/Makefile index f938e8f43..46a351838 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,5 @@ # Image URL to use all building/pushing image targets -#IMG ?= fluxcd/notification-controller:latest -IMG ?= gdasson/notification-controller:v1.0.1-dev +IMG ?= fluxcd/notification-controller:latest # Produce CRDs that work back to Kubernetes 1.16 CRD_OPTIONS ?= crd:crdVersions=v1 SOURCE_VER ?= v1.0.0-rc.3 diff --git a/api/v1beta1/provider_types.go b/api/v1beta1/provider_types.go index b37f536b3..e3622fed8 100644 --- a/api/v1beta1/provider_types.go +++ b/api/v1beta1/provider_types.go @@ -30,7 +30,7 @@ const ( // ProviderSpec defines the desired state of Provider type ProviderSpec struct { // Type of provider - // +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;bitbucketserver;bitbucket;azuredevops;googlechat;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch + // +kubebuilder:validation:Enum=slack;discord;msteams;rocket;generic;generic-hmac;github;gitlab;bitbucket;azuredevops;googlechat;webex;sentry;azureeventhub;telegram;lark;matrix;opsgenie;alertmanager;grafana;githubdispatch; // +required Type string `json:"type"` @@ -77,28 +77,27 @@ type ProviderSpec struct { } const ( - 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" - BitbucketServerProvider string = "bitbucketserver" - BitbucketProvider string = "bitbucket" - AzureDevOpsProvider string = "azuredevops" - GoogleChatProvider string = "googlechat" - WebexProvider string = "webex" - SentryProvider string = "sentry" - AzureEventHubProvider string = "azureeventhub" - TelegramProvider string = "telegram" - LarkProvider string = "lark" - Matrix string = "matrix" - OpsgenieProvider string = "opsgenie" - AlertManagerProvider string = "alertmanager" + 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" + BitbucketProvider string = "bitbucket" + AzureDevOpsProvider string = "azuredevops" + GoogleChatProvider string = "googlechat" + WebexProvider string = "webex" + SentryProvider string = "sentry" + AzureEventHubProvider string = "azureeventhub" + TelegramProvider string = "telegram" + LarkProvider string = "lark" + Matrix string = "matrix" + OpsgenieProvider string = "opsgenie" + AlertManagerProvider string = "alertmanager" ) // ProviderStatus defines the observed state of Provider diff --git a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml index e2e14cb0d..032076c16 100644 --- a/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml +++ b/config/crd/bases/notification.toolkit.fluxcd.io_providers.yaml @@ -94,7 +94,6 @@ spec: - generic-hmac - github - gitlab - - bitbucketserver - bitbucket - azuredevops - googlechat diff --git a/docs/spec/v1beta1/providers.md b/docs/spec/v1beta1/providers.md index cff5c2063..b36f92cf5 100644 --- a/docs/spec/v1beta1/providers.md +++ b/docs/spec/v1beta1/providers.md @@ -46,10 +46,11 @@ type ProviderSpec struct { } ``` + Notification providers: -| Provider | Type | -| ------------------------- | -------------- | +| Provider | Type | +| --------------- | -------------- | | Alertmanager | alertmanager | | Azure Event Hub | azureeventhub | | Discord | discord | @@ -70,13 +71,12 @@ Notification providers: Git commit status providers: -| Provider | Type | -| ---------------- | --------------- | -| Azure DevOps | azuredevops | -| Bitbucket | bitbucket | -| Bitbucket Server | bitbucketserver | -| GitHub | github | -| GitLab | gitlab | +| Provider | Type | +| ------------ | ----------- | +| Azure DevOps | azuredevops | +| Bitbucket | bitbucket | +| GitHub | github | +| GitLab | gitlab | Status: @@ -168,19 +168,19 @@ The body of the request looks like this: ```json { "involvedObject": { - "kind": "GitRepository", - "namespace": "flux-system", - "name": "flux-system", - "uid": "cc4d0095-83f4-4f08-98f2-d2e9f3731fb9", - "apiVersion": "source.toolkit.fluxcd.io/v1beta2", - "resourceVersion": "56921" + "kind":"GitRepository", + "namespace":"flux-system", + "name":"flux-system", + "uid":"cc4d0095-83f4-4f08-98f2-d2e9f3731fb9", + "apiVersion":"source.toolkit.fluxcd.io/v1beta2", + "resourceVersion":"56921", }, - "severity": "info", - "timestamp": "2006-01-02T15:04:05Z", - "message": "Fetched revision: main/731f7eaddfb6af01cb2173e18f0f75b0ba780ef1", - "reason": "info", - "reportingController": "source-controller", - "reportingInstance": "source-controller-7c7b47f5f-8bhrp" + "severity":"info", + "timestamp":"2006-01-02T15:04:05Z", + "message":"Fetched revision: main/731f7eaddfb6af01cb2173e18f0f75b0ba780ef1", + "reason":"info", + "reportingController":"source-controller", + "reportingInstance":"source-controller-7c7b47f5f-8bhrp", } ``` @@ -208,8 +208,8 @@ metadata: namespace: default stringData: headers: | - Authorization: token - X-Forwarded-Proto: https + Authorization: token + X-Forwarded-Proto: https ``` ### Generic webhook with HMAC @@ -280,8 +280,8 @@ It is possible to use a Slack App bot integration to send messages. To obtain a Differences from the Slack [webhook method](#notifications): -- Possible to use single credentials to post to different channels (by adding the integration to each channel) -- All messages are posted with the app username, and not the name of the controller (e.g. `helm-controller`, `source-controller`) +* Possible to use single credentials to post to different channels (by adding the integration to each channel) +* All messages are posted with the app username, and not the name of the controller (e.g. `helm-controller`, `source-controller`) To enable the Slack App, the secret must contain the URL of the [chat.postMessage](https://api.slack.com/methods/chat.postMessage) method and your Slack bot token (starts with `xoxb-`): @@ -390,7 +390,6 @@ For Matrix, the address is the homeserver URL and the token is the access token returned by a call to `/login` or `/register`. Create a secret: - ``` kubectl create secret generic matrix-token \ --from-literal=token= \ @@ -489,8 +488,9 @@ If a summary is provided in the alert resource an additional "summary" annotatio The provider will send the following labels for the event. + | Label | Description | -| --------- | ---------------------------------------------------------------------------------------------------- | +|-----------|------------------------------------------------------------------------------------------------------| | alertname | The string Flux followed by the Kind and the reason for the event e.g `FluxKustomizationProgressing` | | severity | The severity of the event (`error` or `info`) | | timestamp | The timestamp of the event | @@ -504,17 +504,16 @@ The provider will send the following labels for the event. General steps on how to hook up Flux notifications to a Webex space: From the Webex App UI: - - create a Webex space where you want notifications to be sent - after creating a Webex bot (described in next section), add the bot email address to the Webex space ("People | Add people") Register to https://developer.webex.com/, after signing in: - - create a bot for forwarding FluxCD notifications to a Webex Space (User profile icon | MyWebexApps | Create a New App | Create a Bot) - make a note of the bot email address, this email needs to be added to the Webex space from the Webex App - generate a bot access token, this is the ID to use in the kubernetes Secret "token" field (see example below) - find the room ID associated to the webex space using https://developer.webex.com/docs/api/v1/rooms/list-rooms (select GET, click on "Try It" and search the GET results for the matching Webex space entry), this is the ID to use in the webex Provider manifest "channel" field + Manifests template to use: ```yaml @@ -545,10 +544,9 @@ Notes: - spec.address should always be set to the same global Webex API gateway https://webexapis.com/v1/messages - spec.channel should contain the Webex space room ID as obtained from https://developer.webex.com/ (long alphanumeric string copied as is) - token in the Secret manifest is the bot access token generated after creating the bot (as for all secrets, must be base64 encoded using for example - "echo -n | base64") +"echo -n | base64") If you do not see any notifications in the targeted Webex space: - - check that you have applied an Alert with the right even sources and providerRef - check the notification controller log for any error messages - check that you have added the bot email address to the Webex space, if the bot email address is not added to the space, the notification controller will log a 404 room not found error every time a notification is sent out @@ -587,17 +585,18 @@ spec: eventSeverity: info eventSources: - kind: GitRepository - name: "*" + name: '*' - kind: HelmRelease - name: "*" + name: '*' - kind: HelmRepository - name: "*" + name: '*' - kind: Kustomization - name: "*" + name: '*' - kind: OCIRepository - name: "*" + name: '*' ``` + ### Grafana To send notifications to [Grafana annotations API](https://grafana.com/docs/grafana/latest/http_api/annotations/), @@ -607,7 +606,6 @@ you have to enable the annotations on a Dashboard like so: - Annotations > Query > Tags (Add Tag: `flux`) If Grafana has authentication configured, create a Kubernetes Secret with the API URL and the API token: - ```shell kubectl create secret generic grafana-token \ --from-literal=token= \ @@ -616,7 +614,6 @@ kubectl create secret generic grafana-token \ Grafana can also use `basic authorization` to authenticate the requests, if both token and username/password are set in the secret, then `API token` takes precedence over `basic auth`. - ```shell kubectl create secret generic grafana-token \ --from-literal=username= \ @@ -639,7 +636,7 @@ spec: ### Git commit status -The GitHub, GitLab, Bitbucket, Bitbucket Server, and Azure DevOps provider will write to the +The GitHub, GitLab, Bitbucket, and Azure DevOps provider will write to the commit status in the git repository from which the event originates from. {{% alert color="info" title="Limitations" %}} @@ -668,7 +665,6 @@ For bitbucket, the token should contain the username and [app password](https:// in the format `:`. The app password should have `Repositories (Read/Write)` permission. You can create the secret using this command: - ```shell kubectl create secret generic api-token --from-literal=token=: ``` @@ -708,50 +704,6 @@ data: token: : ``` -For bitbucketserver (a.k.a. Bitbucket Data Center), the following auth methods are available:
a) Basic Auth(username/password) -
-b) [HTTP access tokens](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) - -For Basic Auth(username/password), the secret should be created in following format: - -``` -apiVersion: v1 -data: - password: Qml0YnVja2V0QDIwMjM= - username: Zm9vYmFydXNlcg== -kind: Secret -metadata: - name: bb-server-username-password -type: Opaque -``` - -You may create the secret using this command as well: - -```shell -kubectl create secret generic bb-server-username-password --from-literal=username= --from-literal=password= -``` - -For HTTP access tokens, the secret should be created in following format: - -``` -apiVersion: v1 -data: - token: QkJEQy1PREl4T0RZeE16SXlOelV5T3R0b3JNak8wNTlQMnJZVGI2RUg3bVBPTTVUbw== -kind: Secret -metadata: - name: bb-server-token -type: Opaque -``` - -You may create the secret using this command as well: - -```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`. - Opsgenie uses an api key to authenticate [api key](https://support.atlassian.com/opsgenie/docs/api-key-management/). The providers require a secret in the same format, with the api key as the value for the token key: @@ -775,9 +727,9 @@ and [SAS](https://docs.microsoft.com/en-us/azure/event-hubs/authorize-access-sha In JWT we use 3 input values. Channel, token and address. We perform the following translation to match we the data we need to communicate with Azure Event Hub. -- channel = Azure Event Hub namespace -- address = Azure Event Hub name -- token = JWT +* channel = Azure Event Hub namespace +* address = Azure Event Hub name +* token = JWT ```yaml apiVersion: notification.toolkit.fluxcd.io/v1beta1 @@ -870,32 +822,30 @@ The `githubdispatch` provider generates GitHub events of type [`repository_dispa The request includes the `event_type` and `client_payload` fields: -- The `event_type` is generated by GitHub Dispatch provider by combining the Kind, Name and Namespace of the involved object in the format `{Kind}/{Name}.{Namespace}`. For example, the `event_type` for a Flux Kustomization named `podinfo` in the `flux-system` namespace looks like this: `Kustomization/podinfo.flux-system`. +* The `event_type` is generated by GitHub Dispatch provider by combining the Kind, Name and Namespace of the involved object in the format `{Kind}/{Name}.{Namespace}`. For example, the `event_type` for a Flux Kustomization named `podinfo` in the `flux-system` namespace looks like this: `Kustomization/podinfo.flux-system`. -- The `client_payload` contains the Kubernetes event issued by Flux, e.g.: +* The `client_payload` contains the Kubernetes event issued by Flux, e.g.: ```yaml { - involvedObject: - { - apiVersion: kustomize.toolkit.fluxcd.io/v1beta2, - kind: Kustomization, - name: podinfo, - namespace: flux-system, - resourceVersion: 426573, - uid: b9b8554d-be26-4a3d-a97f-65f3276a097a, - }, + involvedObject: { + apiVersion: kustomize.toolkit.fluxcd.io/v1beta2, + kind: Kustomization, + name: podinfo, + namespace: flux-system, + resourceVersion: 426573, + uid: b9b8554d-be26-4a3d-a97f-65f3276a097a + }, message: Deployment/podinfo/podinfo configured, - metadata: - { - revision: main/96139968ca46b53462d1bf94de410a811d2026a1, - summary: "staging (us-west-2)", - }, + metadata: { + revision: main/96139968ca46b53462d1bf94de410a811d2026a1, + summary: "staging (us-west-2)" + }, reason: Progressing, reportingController: kustomize-controller, reportingInstance: kustomize-controller-79464d8dc5-nb9c4, severity: info, - timestamp: 2022-04-20T12:20:28Z, + timestamp: 2022-04-20T12:20:28Z } ``` @@ -918,7 +868,7 @@ The `address` is the address of your repository where you want to send webhooks GitHub uses personal access tokens for authentication with its API: -- [GitHub personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) +* [GitHub personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) The provider requires a secret in the same format, with the personal access token as the value for the token key: @@ -986,7 +936,7 @@ spec: eventSeverity: info eventSources: - kind: Kustomization - name: "podinfo" + name: 'podinfo' ``` Now you can the trigger tests in the GitHub workflow for app1 in a staging cluster when the app1 resources defined in `./app1/staging/` are reconciled by Flux: @@ -1001,6 +951,6 @@ jobs: if: github.event.client_payload.metadata.summary == 'staging (us-west-2)' runs-on: ubuntu-18.04 steps: - - name: Run tests - run: echo "running tests.." + - name: Run tests + run: echo "running tests.." ``` diff --git a/docs/spec/v1beta2/providers.md b/docs/spec/v1beta2/providers.md index 054d5e9c3..cadae4bf5 100644 --- a/docs/spec/v1beta2/providers.md +++ b/docs/spec/v1beta2/providers.md @@ -35,9 +35,9 @@ spec: eventSeverity: error eventSources: - kind: HelmRepository - name: "*" + name: '*' - kind: HelmRelease - name: "*" + name: '*' ``` In the above example: @@ -105,7 +105,7 @@ A Provider also needs a The supported alerting providers are: | Provider | Type | -| ------------------------------------------------------- | ---------------- | +|---------------------------------------------------------|------------------| | [Generic webhook](#generic-webhook) | `generic` | | [Generic webhook with HMAC](#generic-webhook-with-hmac) | `generic-hmac` | | [Azure Event Hub](#azure-event-hub) | `azureeventhub` | @@ -129,14 +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` | -| [BitbucketServer](#bitbucketserver) | `bitbucketserver` | -| [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 @@ -160,12 +160,12 @@ for example: "metadata": { "kustomize.toolkit.fluxcd.io/revision": "main/731f7eaddfb6af01cb2173e18f0f75b0ba780ef1" }, - "severity": "error", + "severity":"error", "reason": "ValidationFailed", - "message": "service/apps/webapp validation error: spec.type: Unsupported value: Ingress", - "reportingController": "kustomize-controller", - "reportingInstance": "kustomize-controller-7c7b47f5f-8bhrp", - "timestamp": "2022-10-28T07:26:19Z" + "message":"service/apps/webapp validation error: spec.type: Unsupported value: Ingress", + "reportingController":"kustomize-controller", + "reportingInstance":"kustomize-controller-7c7b47f5f-8bhrp", + "timestamp":"2022-10-28T07:26:19Z" } ``` @@ -284,7 +284,7 @@ func handleRequest(w http.ResponseWriter, r *http.Request) { ##### Slack When `.spec.type` is set to `slack`, the controller will send a message for an -[Event](events.md#event-structure) to the provided Slack API [Address](#address). +[Event](events.md#event-structure) to the provided Slack API [Address](#address). The Event will be formatted into a Slack message using an [Attachment](https://api.slack.com/reference/messaging/attachments), with the metadata attached as fields, and the involved object as author. @@ -335,7 +335,7 @@ metadata: name: slack-token namespace: default stringData: - token: xoxb-1234567890-1234567890-1234567890-1234567890 + token: xoxb-1234567890-1234567890-1234567890-1234567890 ``` ###### Slack (legacy) example @@ -404,7 +404,7 @@ metadata: name: msteams-webhook namespace: default stringData: - address: "https://xxx.webhook.office.com/..." + address: "https://xxx.webhook.office.com/..." ``` ##### DataDog @@ -500,9 +500,10 @@ metadata: name: discord-webhook namespace: default stringData: - address: "https://discord.com/api/webhooks/..." + address: "https://discord.com/api/webhooks/..." ``` + ##### Sentry When `.spec.type` is set to `sentry`, the controller will send a payload for @@ -595,7 +596,7 @@ spec: When `.spec.type` is set to `matrix`, the controller will send a payload for an [Event](events.md#event-structure) to the provided Matrix [Address](#address). -The Event will be formatted into a message string, with the metadata attached +The Event will be formatted into a message string, with the metadata attached as a list of key-value pairs, and send as a [`m.room.message` text event](https://spec.matrix.org/v1.3/client-server-api/#mroommessage) to the provided Matrix [Address](#address). @@ -659,7 +660,7 @@ metadata: name: lark-webhook namespace: default stringData: - address: "https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx" + address: "https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx" ``` ##### Rocket @@ -827,7 +828,7 @@ metadata: name: opsgenie-token namespace: default stringData: - token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + token: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` ##### PagerDuty @@ -869,7 +870,6 @@ spec: address: https://events.pagerduty.com channel: ``` - If you are sending to a service integration, it is recommended to set your Alert to filter to only those sources you want to trigger an incident for that service. For example: @@ -903,7 +903,7 @@ with the metadata added to the `labels` fields, and the `message` (and optional In addition to the metadata from the Event, the following labels will be added: | Label | Description | -| --------- | ---------------------------------------------------------------------------------------------------- | +|-----------|------------------------------------------------------------------------------------------------------| | alertname | The string Flux followed by the Kind and the reason for the event e.g `FluxKustomizationProgressing` | | severity | The severity of the event (`error` or `info`) | | timestamp | The timestamp of the event | @@ -941,7 +941,7 @@ metadata: name: alertmanager-address namespace: default stringData: - address: https://username:password@/api/v2/alerts/" + address: https://username:password@/api/v2/alerts/" ``` ##### Webex @@ -999,7 +999,7 @@ The meaning of endpoint here depends on the specific Provider type being used. For the `generic` Provider for example this is an HTTP/S address. For other Provider types this could be a project ID or a namespace. -If the address contains sensitive information such as tokens or passwords, it is +If the address contains sensitive information such as tokens or passwords, it is recommended to store the address in the Kubernetes secret referenced by `.spec.secretRef.name`. When the referenced Secret contains an `address` key, the `.spec.address` value is ignored. @@ -1153,6 +1153,7 @@ When the field is set to `false` or removed, it will resume. ## Working with Providers + ### Grafana To send notifications to [Grafana annotations API](https://grafana.com/docs/grafana/latest/http_api/annotations/), @@ -1223,7 +1224,7 @@ The `address` is the address of your repository where you want to send webhooks GitHub uses personal access tokens for authentication with its API: -- [GitHub personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) +* [GitHub personal access token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/creating-a-personal-access-token) The provider requires a secret in the same format, with the personal access token as the value for the token key: @@ -1297,7 +1298,7 @@ spec: eventSeverity: info eventSources: - kind: Kustomization - name: "podinfo" + name: 'podinfo' ``` Now you can the trigger tests in the GitHub workflow for app1 in a staging cluster when @@ -1313,8 +1314,8 @@ jobs: if: github.event.client_payload.metadata.summary == 'staging (us-west-2)' runs-on: ubuntu-18.04 steps: - - name: Run tests - run: echo "running tests.." + - name: Run tests + run: echo "running tests.." ``` ### Azure Event Hub @@ -1328,8 +1329,8 @@ In JWT we use 3 input values. Channel, token and address. We perform the following translation to match we the data we need to communicate with Azure Event Hub. - channel = Azure Event Hub namespace -- address = Azure Event Hub name -- token = JWT +- address = Azure Event Hub name +- token = JWT ```yaml --- @@ -1484,11 +1485,11 @@ kubectl create secret generic gitlab-token --from-literal=token= When `.spec.type` is set to `gitea`, the referenced secret must contain a key called `token` with the value set to a [Gitea token](https://docs.gitea.io/en-us/api-usage/#generating-and-listing-api-tokens). -The token must have at least the `write:repository` permission for the provider to +The token must have at least the `write:repository` permission for the provider to update the commit status for the Gitea repository specified in `.spec.address`. {{% alert color="info" title="Gitea 1.20.0 & 1.20.1" %}} -Due to a bug in Gitea 1.20.0 and 1.20.1, these versions require the additional +Due to a bug in Gitea 1.20.0 and 1.20.1, these versions require the additional `read:misc` scope to be applied to the token. {{% /alert %}} @@ -1514,44 +1515,22 @@ You can create the secret with `kubectl` like this: kubectl create secret generic bitbucket-token --from-literal=token=: ``` -#### BitBucketServer +#### BitBucket Server/Data Center -For bitbucketserver (a.k.a. Bitbucket Data Center), the following auth methods are available:
a) Basic Auth(username/password) -
-b) [HTTP access tokens](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) +When `.spec.type` is set to `bitbucketserver`, the following auth methods are available: -For Basic Auth(username/password), the secret should be created in following format: +- Basic Authentication (username/password) +- [HTTP access tokens](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) -``` -apiVersion: v1 -data: - password: Qml0YnVja2V0QDIwMjM= - username: Zm9vYmFydXNlcg== -kind: Secret -metadata: - name: bb-server-username-password -type: Opaque -``` +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 may create the secret using this command as well: +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 should be created in following format: - -``` -apiVersion: v1 -data: - token: QkJEQy1PREl4T0RZeE16SXlOelV5T3R0b3JNak8wNTlQMnJZVGI2RUg3bVBPTTVUbw== -kind: Secret -metadata: - name: bb-server-token -type: Opaque -``` - -You may create the secret using this command as well: +For HTTP access tokens, the secret can be created with `kubectl` like this: ```shell kubectl create secret generic bb-server-token --from-literal=token= diff --git a/go.mod b/go.mod index 0eda9ebea..d97e512cb 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,6 @@ require ( github.com/Azure/azure-event-hubs-go/v3 v3.6.1 github.com/DataDog/datadog-api-client-go/v2 v2.15.0 github.com/PagerDuty/go-pagerduty v1.7.0 - github.com/antihax/optional v1.0.0 github.com/containrrr/shoutrrr v0.8.0 github.com/fluxcd/notification-controller/api v1.1.0 github.com/fluxcd/pkg/apis/event v0.5.2 @@ -21,7 +20,6 @@ require ( github.com/fluxcd/pkg/masktoken v0.2.0 github.com/fluxcd/pkg/runtime v0.42.0 github.com/fluxcd/pkg/ssa v0.32.0 - github.com/gdasson/bitbucketv1go v1.0.0 github.com/getsentry/sentry-go v0.23.0 github.com/go-logr/logr v1.2.4 github.com/google/go-github/v53 v53.2.0 @@ -35,7 +33,7 @@ require ( github.com/stretchr/testify v1.8.4 github.com/whilp/git-urls v1.0.0 github.com/xanzy/go-gitlab v0.90.0 - golang.org/x/oauth2 v0.13.0 + golang.org/x/oauth2 v0.11.0 golang.org/x/text v0.13.0 google.golang.org/api v0.138.0 k8s.io/api v0.27.4 @@ -144,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 9aca93dc8..c52504729 100644 --- a/go.sum +++ b/go.sum @@ -663,7 +663,6 @@ github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/v10 v10.0.1/go.mod h1:YvhnlEePVnBS4+0z3fhPfUy7W1Ikj0Ih0vcRo/gZ1M0= github.com/apache/arrow/go/v11 v11.0.0/go.mod h1:Eg5OsL5H+e299f7u5ssuXsuHQVEGC4xei5aX110hRiI= @@ -765,8 +764,6 @@ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzP github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/gdasson/bitbucketv1go v1.0.0 h1:sRiO7XgqY4WaolHruzPufPLRODfSUGUVkWLDHy4Ba5M= -github.com/gdasson/bitbucketv1go v1.0.0/go.mod h1:VKmMS4gxRLRBKoyzNVYc7P+crDC7/UQ97+mVc6OqE34= github.com/getsentry/sentry-go v0.23.0 h1:dn+QRCeJv4pPt9OjVXiMcGIBIefaTJPw/h0bZWO05nE= github.com/getsentry/sentry-go v0.23.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -1308,9 +1305,8 @@ golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= golang.org/x/oauth2 v0.7.0/go.mod h1:hPLQkd9LyjfXTiRohC/41GhcFqxisoUQ99sCUOHO9x4= golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE= +golang.org/x/oauth2 v0.11.0 h1:vPL4xzxBM4niKCW6g9whtaWVXTJf1U5e4aZxxFx/gbU= golang.org/x/oauth2 v0.11.0/go.mod h1:LdF7O/8bLR/qWK9DrpXmbHLTouvRHK0SgJl0GmDBchk= -golang.org/x/oauth2 v0.13.0 h1:jDDenyj+WgFtmV3zYVoi8aE2BwtXFLWOA67ZfNWftiY= -golang.org/x/oauth2 v0.13.0/go.mod h1:/JMhi4ZRXAf4HG9LiNmxvk+45+96RUlVThiH8FzNBn0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1518,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 index eaed63456..5ccf1116b 100644 --- a/internal/notifier/bitbucketserver.go +++ b/internal/notifier/bitbucketserver.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +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. @@ -17,49 +17,76 @@ limitations under the License. package notifier import ( + "bytes" "context" "crypto/tls" "crypto/x509" + "encoding/json" "errors" "fmt" + "io" "net/http" + "net/url" "strings" + "time" - "github.com/antihax/optional" eventv1 "github.com/fluxcd/pkg/apis/event/v1beta1" "github.com/fluxcd/pkg/apis/meta" - "github.com/gdasson/bitbucketv1go" + "github.com/hashicorp/go-retryablehttp" ) -// Bitbucket is a Bitbucket Server notifier. +// BitbucketServer is a notifier for BitBucket Server and Data Center. type BitbucketServer struct { ProjectKey string RepositorySlug string ProviderUID string ProviderAddress string - Credentials *bitbucketv1go.Credentials - Client *bitbucketv1go.APIClient + Host string + Username string + Password string + Token string + Client *retryablehttp.Client } -// NewBitbucket creates and returns a new Bitbucket notifier. +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 } - c := &bitbucketv1go.Credentials{} - if len(token) > 0 { - c.RestBearerTokenCredentials.Token = token - } - if len(username) > 0 && len(password) > 0 { - c.RestUsernamePasswordCredentials.Username = username - c.RestUsernamePasswordCredentials.Password = password - } - if len(token) == 0 && (len(username) == 0 || len(password) == 0) { - return nil, errors.New("invalid credentials, expected to be one of username,password or APIToken") - } - comp := strings.Split(id, "/") if len(comp) != 2 { return nil, fmt.Errorf("invalid repository id %q", id) @@ -67,50 +94,55 @@ func NewBitbucketServer(providerUID string, addr string, token string, certPool projectkey := comp[0] reposlug := comp[1] - bitbucketConfig := bitbucketv1go.NewConfiguration() - bitbucketConfig.BasePath = hst + "/rest" - bitbucketConfig.AddDefaultHeader("x-atlassian-token", "no-check") - bitbucketConfig.AddDefaultHeader("x-requested-with", "XMLHttpRequest") + httpClient := retryablehttp.NewClient() if certPool != nil { - tr := &http.Transport{ + httpClient.HTTPClient.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: certPool, }, } - hc := &http.Client{Transport: tr} - bitbucketConfig.HTTPClient = hc } - bitbucketClient := bitbucketv1go.NewAPIClient(bitbucketConfig) + 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, - Credentials: c, + Host: hst, ProviderAddress: addr, - Client: bitbucketClient, + Token: token, + Username: username, + Password: password, + Client: httpClient, }, nil } -// Post Bitbucket build status +// 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 err + return fmt.Errorf("could not parse revision: %w", err) } - state, err := toBitbucketServerState(event.Severity) + state, err := b.state(event.Severity) if err != nil { - return err + return fmt.Errorf("couldn't convert to bitbucket server state: %w", err) } name, desc := formatNameAndDescription(event) @@ -119,53 +151,144 @@ func (b BitbucketServer) Post(ctx context.Context, event eventv1.Event) error { // key has a limitation of 40 characters in bitbucket api key := sha1String(id) - if len(b.Credentials.RestUsernamePasswordCredentials.Username) > 0 && len(b.Credentials.RestUsernamePasswordCredentials.Password) > 0 { - ctx = context.WithValue(ctx, bitbucketv1go.ContextBasicAuth, bitbucketv1go.BasicAuth{ - UserName: b.Credentials.RestUsernamePasswordCredentials.Username, - Password: b.Credentials.RestUsernamePasswordCredentials.Password, - }) - } - if len(b.Credentials.RestBearerTokenCredentials.Token) > 0 { - ctx = context.WithValue(ctx, bitbucketv1go.ContextAccessToken, b.Credentials.RestBearerTokenCredentials.Token) - } - - existingCommitStatus, httpResponse, err := b.Client.BuildsAndDeploymentsApi.Get(ctx, b.ProjectKey, rev, b.RepositorySlug, - &bitbucketv1go.BuildsAndDeploymentsApiGetOpts{ - Key: optional.NewString(key), - }) - if err != nil && httpResponse.StatusCode != 404 { - return fmt.Errorf("could not get commit status: %v", err) + 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 httpResponse.StatusCode == 200 { - // Do not post duplicate build status - if existingCommitStatus.Key == key && existingCommitStatus.State == state && existingCommitStatus.Description == desc && existingCommitStatus.Name == name { - return nil + 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) } } - _, err = b.Client.BuildsAndDeploymentsApi.Add(ctx, b.ProjectKey, rev, b.RepositorySlug, - &bitbucketv1go.BuildsAndDeploymentsApiAddOpts{ - Body: optional.NewInterface(bitbucketv1go.RestBuildStatusSetRequest{ - Key: key, - State: state, - Url: b.ProviderAddress, - Description: desc, - Name: name, - })}) - if err != nil { - return fmt.Errorf("could not post build status: %v", err) - } return nil } -func toBitbucketServerState(severity string) (string, error) { +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("can't convert to bitbucket server state") + 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 index 0db9963b1..fa8207e18 100644 --- a/internal/notifier/bitbucketserver_test.go +++ b/internal/notifier/bitbucketserver_test.go @@ -1,5 +1,5 @@ /* -Copyright 2020 The Flux authors +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. @@ -17,34 +17,39 @@ 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.Credentials.RestUsernamePasswordCredentials.Username, "dummyuser") - assert.Equal(t, b.Credentials.RestUsernamePasswordCredentials.Password, "testpassword") + 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.Credentials.RestBearerTokenCredentials.Token, "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP") + 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 APIToken") -} - -func TestNewBitbucketServerInvalidUrl(t *testing.T) { - _, err := NewBitbucketServer("0c9c2e41-d2f9-4f9b-9c41-bebc1984d67a", "ssh://git@example.com:7999/projectfoo/repobar.git", "BBDC-ODIxODYxMzIyNzUyOttorMjO059P2rYTb6EH7mP", nil, "", "") - assert.NotNil(t, err) - assert.Equal(t, err.Error(), "Unsupported git scheme ssh in address \"ssh://git@example.com:7999/projectfoo/repobar.git\". Please provide address in http/https format for BitbucketServer provider") + assert.Equal(t, err.Error(), "invalid credentials, expected to be one of username/password or API Token") } func TestNewBitbucketServerInvalidRepo(t *testing.T) { @@ -52,3 +57,322 @@ func TestNewBitbucketServerInvalidRepo(t *testing.T) { 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/util.go b/internal/notifier/util.go index 296a6a9ad..c7b59bde7 100644 --- a/internal/notifier/util.go +++ b/internal/notifier/util.go @@ -47,23 +47,6 @@ func parseGitAddress(s string) (string, string, error) { return host, id, nil } -func parseBitbucketServerGitAddress(s string) (string, string, error) { - u, err := giturls.Parse(s) - if err != nil { - return "", "", fmt.Errorf("failed parsing URL %q: %w", s, err) - } - - scheme := u.Scheme - if u.Scheme != "http" && u.Scheme != "https" { - return "", "", fmt.Errorf("Unsupported git scheme %s in address %q. Please provide address in http/https format for BitbucketServer provider", u.Scheme, s) - } - - id := strings.TrimPrefix(u.Path, "/scm/") //https://community.atlassian.com/t5/Bitbucket-questions/remote-url-in-Bitbucket-server-what-does-scm-represent-is-it/qaq-p/2060987 - id = strings.TrimSuffix(id, ".git") - host := fmt.Sprintf("%s://%s", scheme, u.Host) - return host, id, nil -} - func formatNameAndDescription(event eventv1.Event) (string, string) { name := fmt.Sprintf("%v/%v", event.InvolvedObject.Kind, event.InvolvedObject.Name) name = strings.ToLower(name)