feat: add http webhook (#5233)

Signed-off-by: Piotr Pawluk <piotr.pawluk@zendesk.com>
Co-authored-by: PePe Amengual <2208324+jamengual@users.noreply.github.com>
This commit is contained in:
Piotr Pawluk
2025-01-23 04:39:20 +01:00
committed by GitHub
parent a189c956cd
commit 46e181e725
14 changed files with 537 additions and 108 deletions

View File

@@ -153,6 +153,7 @@ const (
TFELocalExecutionModeFlag = "tfe-local-execution-mode"
TFETokenFlag = "tfe-token"
WriteGitCredsFlag = "write-git-creds" // nolint: gosec
WebhookHttpHeaders = "webhook-http-headers"
WebBasicAuthFlag = "web-basic-auth"
WebUsernameFlag = "web-username"
WebPasswordFlag = "web-password"
@@ -460,6 +461,12 @@ var stringFlags = map[string]stringFlag{
description: "Name used to identify Atlantis for pull request statuses.",
defaultValue: DefaultVCSStatusName,
},
WebhookHttpHeaders: {
description: "Additional headers added to each HTTP POST payload when using HTTP webhooks provided as a JSON string." +
" The map key is the header name and the value is the header value (string) or values (array of string)." +
" For example: `{\"Authorization\":\"Bearer some-token\",\"X-Custom-Header\":[\"value1\",\"value2\"]}`.",
defaultValue: "",
},
WebUsernameFlag: {
description: "Username used for Web Basic Authentication on Atlantis HTTP Middleware",
defaultValue: DefaultWebUsername,
@@ -1069,6 +1076,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
return errors.Wrapf(err, "invalid --%s", AllowCommandsFlag)
}
if _, err := userConfig.ToWebhookHttpHeaders(); err != nil {
return errors.Wrapf(err, "invalid --%s", WebhookHttpHeaders)
}
return nil
}

View File

@@ -151,6 +151,7 @@ var testFlags = map[string]interface{}{
VarFileAllowlistFlag: "/path",
VCSStatusName: "my-status",
IgnoreVCSStatusNames: "",
WebhookHttpHeaders: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`,
WebBasicAuthFlag: false,
WebPasswordFlag: "atlantis",
WebUsernameFlag: "atlantis",

View File

@@ -44,7 +44,7 @@ const en = [
{ text: "Checkout Strategy", link: "/docs/checkout-strategy" },
{ text: "Terraform Versions", link: "/docs/terraform-versions" },
{ text: "Terraform Cloud", link: "/docs/terraform-cloud" },
{ text: "Using Slack Hooks", link: "/docs/using-slack-hooks" },
{ text: "Sending Notifications via Webhooks", link: "/docs/sending-notifications-via-webhooks" },
{ text: "Stats", link: "/docs/stats" },
{ text: "FAQ", link: "/docs/faq" },
]

View File

@@ -0,0 +1,151 @@
# Sending notifications via webhooks
It is possible to send notifications to external systems whenever an apply is being done.
You can make requests to any HTTP endpoint or send messages directly to your Slack channel.
::: tip NOTE
Currently only `apply` events are supported.
:::
## Configuration
Webhooks are configured in Atlantis [server-side configuration](server-configuration.md).
There can be many webhooks: sending notifications to different destinations or for different
workspaces/branches. Here is example configuration to send Slack messages for every apply:
```yaml
webhooks:
- event: apply
kind: slack
channel: my-channel-id
```
If you are deploying Atlantis as a Helm chart, this can be implemented via the `config` parameter available for [chart customizations](https://github.com/runatlantis/helm-charts#customization):
```yaml
## Use Server Side Config,
## ref: https://www.runatlantis.io/docs/server-configuration.html
config: |
---
webhooks:
- event: apply
kind: slack
channel: my-channel-id
```
### Filter on workspace/branch
To limit notifications to particular workspaces or branches, use `workspace-regex` or `branch-regex` parameters.
If the workspace **and** branch matches respective regex, an event will be sent. Note that empty regular expression
(a result of unset parameter) matches every string.
## Using HTTP webhooks
You can send POST requests with JSON payload to any HTTP/HTTPS server.
### Configuring Atlantis
In your Atlantis [server-side configuration](server-configuration.md) you can add the following:
```yaml
webhooks:
- event: apply
kind: http
url: https://example.com/hooks
```
The `apply` event information will be POSTed to `https://example.com/hooks`.
You can supply any additional headers with `--webhook-http-headers` parameter (or environment variable),
for example for authentication purposes. See [webhook-http-headers](server-configuration.md#webhook-http-headers) for details.
### JSON payload
The payload is a JSON-marshalled [ApplyResult](https://pkg.go.dev/github.com/runatlantis/atlantis/server/events/webhooks#ApplyResult) struct.
Example payload:
```json
{
"Workspace": "default",
"Repo": {
"FullName": "octocat/Hello-World",
"Owner": "octocat",
"Name": "Hello-World",
"CloneURL": "https://:@github.com/octocat/Hello-World.git",
"SanitizedCloneURL": "https://:<redacted>@github.com/octocat/Hello-World.git",
"VCSHost": {
"Hostname": "github.com",
"Type": 0
}
},
"Pull": {
"Num": 2137,
"HeadCommit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
"URL": "https://github.com/octocat/Hello-World/pull/2137",
"HeadBranch": "feature/some-branch",
"BaseBranch": "main",
"Author": "octocat",
"State": 0,
"BaseRepo": {
"FullName": "octocat/Hello-World",
"Owner": "octocat",
"Name": "Hello-World",
"CloneURL": "https://:@github.com/octocat/Hello-World.git",
"SanitizedCloneURL": "https://:<redacted>@github.com/octocat/Hello-World.git",
"VCSHost": {
"Hostname": "github.com",
"Type": 0
}
}
},
"User": {
"Username": "octocat",
"Teams": null
},
"Success": true,
"Directory": "terraform/example",
"ProjectName": "example-project"
}
```
## Using Slack hooks
For this you'll need to:
* Create a Bot user in Slack
* Configure Atlantis to send notifications to Slack.
### Configuring Slack for Atlantis
* Go to [Slack: Apps](https://api.slack.com/apps)
* Click the `Create New App` button
* Select `From scratch` in the dialog that opens
* Give it a name, e.g. `atlantis-bot`.
* Select your Slack workspace
* Click `Create App`
* On the left go to `oAuth & Permissions`
* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes:
* `channels:read`
* `chat:write`
* `groups:read`
* `incoming-webhook`
* `mpim:read`
* Install the app onto your Slack workspace
* Copy the `Bot User OAuth Token` and provide it to Atlantis by using `--slack-token=xoxb-xxxxxxxxxxx` or via the environment `ATLANTIS_SLACK_TOKEN=xoxb-xxxxxxxxxxx`.
* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing
* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps"
### Configuring Atlantis
After following the above steps it is time to configure Atlantis. Assuming you have already provided the `slack-token` (via parameter or environment variable) you can now instruct Atlantis to send `apply` events to Slack.
In your Atlantis [server-side configuration](server-configuration.md) you can now add the following:
```yaml
webhooks:
- event: apply
kind: slack
channel: my-channel-id
```

View File

@@ -1254,7 +1254,7 @@ This is useful when you have many projects and want to keep the pull request cle
ATLANTIS_SLACK_TOKEN='token'
```
API token for Slack notifications. See [Using Slack hooks](using-slack-hooks.md).
API token for Slack notifications. See [Using Slack hooks](sending-notifications-via-webhooks.md#using-slack-hooks).
### `--ssl-cert-file`
@@ -1425,6 +1425,18 @@ The effect of the race condition is more evident when using parallel configurati
Username used for Basic Authentication on the Atlantis web service. Defaults to `atlantis`.
### `--webhook-http-headers`
```bash
atlantis server --webhook-http-headers='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}'
# or
ATLANTIS_WEBHOOK_HTTP_HEADERS='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}'
```
Additional headers added to each HTTP POST payload when using [http webhooks](sending-notifications-via-webhooks.md#using-http-webhooks)
provided as a JSON string. The map key is the header name and the value is the header value
(string) or values (array of string).
### `--websocket-check-origin`
```bash

View File

@@ -1,64 +0,0 @@
# Using Slack hooks
It is possible to use Slack to send notifications to your Slack channel whenever an apply is being done.
::: tip NOTE
Currently only `apply` events are supported.
:::
For this you'll need to:
* Create a Bot user in Slack
* Configure Atlantis to send notifications to Slack.
## Configuring Slack for Atlantis
* Go to [Slack: Apps](https://api.slack.com/apps)
* Click the `Create New App` button
* Select `From scratch` in the dialog that opens
* Give it a name, e.g. `atlantis-bot`.
* Select your Slack workspace
* Click `Create App`
* On the left go to `oAuth & Permissions`
* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes:
* `channels:read`
* `chat:write`
* `groups:read`
* `incoming-webhook`
* `mpim:read`
* Install the app onto your Slack workspace
* Copy the `Bot User OAuth Token` and provide it to Atlantis by using `--slack-token=xoxb-xxxxxxxxxxx` or via the environment `ATLANTIS_SLACK_TOKEN=xoxb-xxxxxxxxxxx`.
* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing
* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps"
## Configuring Atlantis
After following the above steps it is time to configure Atlantis. Assuming you have already provided the `slack-token` (via parameter or environment variable) you can now instruct Atlantis to send `apply` events to Slack.
In your Atlantis configuration you can now add the following:
```yaml
webhooks:
- event: apply
workspace-regex: .*
branch-regex: .*
kind: slack
channel: my-channel-id
```
If you are deploying Atlantis as a Helm chart, this can be implemented via the `config` parameter available for [chart customizations](https://github.com/runatlantis/helm-charts#customization):
```yaml
## Use Server Side Config,
## ref: https://www.runatlantis.io/docs/server-configuration.html
config: |
---
webhooks:
- event: apply
workspace-regex: .*
branch-regex: .*
kind: slack
channel: my-channel-id
```
The `apply` event information will be sent to the `my-channel-id` Slack channel.

View File

@@ -660,12 +660,13 @@ func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (apply
outputs, err := p.runSteps(ctx.Steps, ctx, absPath)
p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck
Workspace: ctx.Workspace,
User: ctx.User,
Repo: ctx.Pull.BaseRepo,
Pull: ctx.Pull,
Success: err == nil,
Directory: ctx.RepoRelDir,
Workspace: ctx.Workspace,
User: ctx.User,
Repo: ctx.Pull.BaseRepo,
Pull: ctx.Pull,
Success: err == nil,
Directory: ctx.RepoRelDir,
ProjectName: ctx.ProjectName,
})
if err != nil {

View File

@@ -0,0 +1,65 @@
package webhooks
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/logging"
)
// HttpWebhook sends webhooks to any HTTP destination.
type HttpWebhook struct {
Client *HttpClient
WorkspaceRegex *regexp.Regexp
BranchRegex *regexp.Regexp
URL string
}
// Send sends the webhook to URL if workspace and branch matches their respective regex.
func (h *HttpWebhook) Send(_ logging.SimpleLogging, applyResult ApplyResult) error {
if !h.WorkspaceRegex.MatchString(applyResult.Workspace) || !h.BranchRegex.MatchString(applyResult.Pull.BaseBranch) {
return nil
}
if err := h.doSend(applyResult); err != nil {
return errors.Wrap(err, fmt.Sprintf("sending webhook to %q", h.URL))
}
return nil
}
func (h *HttpWebhook) doSend(applyResult ApplyResult) error {
body, err := json.Marshal(applyResult)
if err != nil {
return err
}
req, err := http.NewRequest("POST", h.URL, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
for header, values := range h.Client.Headers {
for _, value := range values {
req.Header.Add(header, value)
}
}
resp, err := h.Client.Client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("returned status code %d with response %q", resp.StatusCode, respBody)
}
return nil
}
// HttpClient wraps http.Client allowing to add arbitrary Headers to a request.
type HttpClient struct {
Client *http.Client
Headers map[string][]string
}

View File

@@ -0,0 +1,127 @@
package webhooks_test
import (
"net/http"
"net/http/httptest"
"regexp"
"testing"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/webhooks"
"github.com/runatlantis/atlantis/server/logging"
. "github.com/runatlantis/atlantis/testing"
)
var httpApplyResult = webhooks.ApplyResult{
Workspace: "production",
Repo: models.Repo{
FullName: "runatlantis/atlantis",
},
Pull: models.PullRequest{
Num: 1,
URL: "url",
BaseBranch: "main",
},
User: models.User{
Username: "lkysow",
},
Success: true,
}
func TestHttpWebhookWithHeaders(t *testing.T) {
expectedHeaders := map[string][]string{
"Authorization": {"Bearer token"},
"X-Custom-Header": {"value1", "value2"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Equals(t, r.Header.Get("Content-Type"), "application/json")
for k, v := range expectedHeaders {
Equals(t, r.Header.Values(k), v)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := webhooks.HttpWebhook{
Client: &webhooks.HttpClient{Client: http.DefaultClient, Headers: expectedHeaders},
URL: server.URL,
WorkspaceRegex: regexp.MustCompile(".*"),
BranchRegex: regexp.MustCompile(".*"),
}
err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
Ok(t, err)
}
func TestHttpWebhookNoHeaders(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Equals(t, r.Header.Get("Content-Type"), "application/json")
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
webhook := webhooks.HttpWebhook{
Client: &webhooks.HttpClient{Client: http.DefaultClient},
URL: server.URL,
WorkspaceRegex: regexp.MustCompile(".*"),
BranchRegex: regexp.MustCompile(".*"),
}
err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
Ok(t, err)
}
func TestHttpWebhook500(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
webhook := webhooks.HttpWebhook{
Client: &webhooks.HttpClient{Client: http.DefaultClient},
URL: server.URL,
WorkspaceRegex: regexp.MustCompile(".*"),
BranchRegex: regexp.MustCompile(".*"),
}
err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
ErrContains(t, "sending webhook", err)
}
func TestHttpNoRegexMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Assert(t, false, "webhook should not be sent")
}))
defer server.Close()
tt := []struct {
name string
wr *regexp.Regexp
br *regexp.Regexp
}{
{
name: "no workspace match",
wr: regexp.MustCompile("other"),
br: regexp.MustCompile(".*"),
},
{
name: "no branch match",
wr: regexp.MustCompile(".*"),
br: regexp.MustCompile("other"),
},
}
for _, tc := range tt {
t.Run(tc.name, func(t *testing.T) {
webhook := webhooks.HttpWebhook{
Client: &webhooks.HttpClient{Client: http.DefaultClient},
URL: server.URL,
WorkspaceRegex: tc.wr,
BranchRegex: tc.br,
}
err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult)
Ok(t, err)
})
}
}

View File

@@ -24,6 +24,7 @@ import (
)
const SlackKind = "slack"
const HttpKind = "http"
const ApplyEvent = "apply"
//go:generate pegomock generate --package mocks -o mocks/mock_sender.go Sender
@@ -36,12 +37,13 @@ type Sender interface {
// ApplyResult is the result of a terraform apply.
type ApplyResult struct {
Workspace string
Repo models.Repo
Pull models.PullRequest
User models.User
Success bool
Directory string
Workspace string
Repo models.Repo
Pull models.PullRequest
User models.User
Success bool
Directory string
ProjectName string
}
// MultiWebhookSender sends multiple webhooks for each one it's configured for.
@@ -55,9 +57,15 @@ type Config struct {
BranchRegex string
Kind string
Channel string
URL string
}
func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookSender, error) {
type Clients struct {
Slack SlackClient
Http *HttpClient
}
func NewMultiWebhookSender(configs []Config, clients Clients) (*MultiWebhookSender, error) {
var webhooks []Sender
for _, c := range configs {
wr, err := regexp.Compile(c.WorkspaceRegex)
@@ -76,19 +84,30 @@ func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookS
}
switch c.Kind {
case SlackKind:
if !client.TokenIsSet() {
if !clients.Slack.TokenIsSet() {
return nil, errors.New("must specify top-level \"slack-token\" if using a webhook of \"kind: slack\"")
}
if c.Channel == "" {
return nil, errors.New("must specify \"channel\" if using a webhook of \"kind: slack\"")
}
slack, err := NewSlack(wr, br, c.Channel, client)
slack, err := NewSlack(wr, br, c.Channel, clients.Slack)
if err != nil {
return nil, err
}
webhooks = append(webhooks, slack)
case HttpKind:
if c.URL == "" {
return nil, errors.New("must specify \"url\" if using a webhook of \"kind: http\"")
}
httpWebhook := &HttpWebhook{
Client: clients.Http,
WorkspaceRegex: wr,
BranchRegex: br,
URL: c.URL,
}
webhooks = append(webhooks, httpWebhook)
default:
return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" is supported right now", c.Kind, SlackKind)
return nil, fmt.Errorf("\"kind: %s\" not supported. Only \"kind: %s\" and \"kind: %s\" are supported right now", c.Kind, SlackKind, HttpKind)
}
}
@@ -101,7 +120,7 @@ func NewMultiWebhookSender(configs []Config, client SlackClient) (*MultiWebhookS
func (w *MultiWebhookSender) Send(log logging.SimpleLogging, result ApplyResult) error {
for _, w := range w.Webhooks {
if err := w.Send(log, result); err != nil {
log.Warn("error sending slack webhook: %s", err)
log.Warn("error sending webhook: %s", err)
}
}
return nil

View File

@@ -14,6 +14,7 @@
package webhooks_test
import (
"net/http"
"strings"
"testing"
@@ -43,15 +44,22 @@ func validConfigs() []webhooks.Config {
return []webhooks.Config{validConfig}
}
func validClients() webhooks.Clients {
return webhooks.Clients{
Slack: mocks.NewMockSlackClient(),
Http: &webhooks.HttpClient{Client: http.DefaultClient},
}
}
func TestNewWebhooksManager_InvalidWorkspaceRegex(t *testing.T) {
t.Log("When given an invalid workspace regex in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
invalidRegex := "("
configs := validConfigs()
configs[0].WorkspaceRegex = invalidRegex
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error")
}
@@ -59,12 +67,12 @@ func TestNewWebhooksManager_InvalidWorkspaceRegex(t *testing.T) {
func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) {
t.Log("When given an invalid branch regex in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
invalidRegex := "("
configs := validConfigs()
configs[0].BranchRegex = invalidRegex
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error")
}
@@ -72,13 +80,13 @@ func TestNewWebhooksManager_InvalidBranchRegex(t *testing.T) {
func TestNewWebhooksManager_InvalidBranchAndWorkspaceRegex(t *testing.T) {
t.Log("When given an invalid branch and invalid workspace regex in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
invalidRegex := "("
configs := validConfigs()
configs[0].WorkspaceRegex = invalidRegex
configs[0].BranchRegex = invalidRegex
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Assert(t, strings.Contains(err.Error(), "error parsing regexp"), "expected regex error")
}
@@ -86,10 +94,10 @@ func TestNewWebhooksManager_InvalidBranchAndWorkspaceRegex(t *testing.T) {
func TestNewWebhooksManager_NoEvent(t *testing.T) {
t.Log("When the event key is not specified in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
configs := validConfigs()
configs[0].Event = ""
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error())
}
@@ -97,12 +105,12 @@ func TestNewWebhooksManager_NoEvent(t *testing.T) {
func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) {
t.Log("When given an unsupported event in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
unsupportedEvent := "badevent"
configs := validConfigs()
configs[0].Event = unsupportedEvent
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Equals(t, "\"event: badevent\" not supported. Only \"event: apply\" is supported right now", err.Error())
}
@@ -110,10 +118,10 @@ func TestNewWebhooksManager_UnsupportedEvent(t *testing.T) {
func TestNewWebhooksManager_NoKind(t *testing.T) {
t.Log("When the kind key is not specified in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
configs := validConfigs()
configs[0].Kind = ""
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Equals(t, "must specify \"kind\" and \"event\" keys for webhooks", err.Error())
}
@@ -121,14 +129,14 @@ func TestNewWebhooksManager_NoKind(t *testing.T) {
func TestNewWebhooksManager_UnsupportedKind(t *testing.T) {
t.Log("When given an unsupported kind in a config, an error is returned")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
clients := validClients()
unsupportedKind := "badkind"
configs := validConfigs()
configs[0].Kind = unsupportedKind
_, err := webhooks.NewMultiWebhookSender(configs, client)
_, err := webhooks.NewMultiWebhookSender(configs, clients)
Assert(t, err != nil, "expected error")
Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" is supported right now", err.Error())
Equals(t, "\"kind: badkind\" not supported. Only \"kind: slack\" and \"kind: http\" are supported right now", err.Error())
}
func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) {
@@ -136,23 +144,27 @@ func TestNewWebhooksManager_NoConfigSuccess(t *testing.T) {
t.Log("passing any client should succeed")
var emptyConfigs []webhooks.Config
emptyToken := ""
m, err := webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.NewSlackClient(emptyToken))
anyClients := webhooks.Clients{
Slack: webhooks.NewSlackClient(emptyToken),
Http: &webhooks.HttpClient{Client: http.DefaultClient},
}
m, err := webhooks.NewMultiWebhookSender(emptyConfigs, anyClients)
Ok(t, err)
Equals(t, 0, len(m.Webhooks)) // nolint: staticcheck
t.Log("passing nil client should succeed")
m, err = webhooks.NewMultiWebhookSender(emptyConfigs, nil)
m, err = webhooks.NewMultiWebhookSender(emptyConfigs, webhooks.Clients{})
Ok(t, err)
Equals(t, 0, len(m.Webhooks)) // nolint: staticcheck
}
func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) {
t.Log("When there is one valid config, function should succeed")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
When(client.TokenIsSet()).ThenReturn(true)
clients := validClients()
When(clients.Slack.TokenIsSet()).ThenReturn(true)
configs := validConfigs()
m, err := webhooks.NewMultiWebhookSender(configs, client)
m, err := webhooks.NewMultiWebhookSender(configs, clients)
Ok(t, err)
Equals(t, 1, len(m.Webhooks)) // nolint: staticcheck
}
@@ -160,15 +172,15 @@ func TestNewWebhooksManager_SingleConfigSuccess(t *testing.T) {
func TestNewWebhooksManager_MultipleConfigSuccess(t *testing.T) {
t.Log("When there are multiple valid configs, function should succeed")
RegisterMockTestingT(t)
client := mocks.NewMockSlackClient()
When(client.TokenIsSet()).ThenReturn(true)
clients := validClients()
When(clients.Slack.TokenIsSet()).ThenReturn(true)
var configs []webhooks.Config
nConfigs := 5
for i := 0; i < nConfigs; i++ {
configs = append(configs, validConfig)
}
m, err := webhooks.NewMultiWebhookSender(configs, client)
m, err := webhooks.NewMultiWebhookSender(configs, clients)
Ok(t, err)
Equals(t, nConfigs, len(m.Webhooks)) // nolint: staticcheck
}

View File

@@ -149,11 +149,14 @@ type WebhookConfig struct {
// that is being modified for this event. If the regex matches, we'll
// send the webhook, ex. "main.*".
BranchRegex string `mapstructure:"branch-regex"`
// Kind is the type of webhook we should send, ex. slack.
// Kind is the type of webhook we should send, ex. slack or http.
Kind string `mapstructure:"kind"`
// Channel is the channel to send this webhook to. It only applies to
// slack webhooks. Should be without '#'.
Channel string `mapstructure:"channel"`
// URL is the URL where to deliver this webhook. It only applies to
// http webhooks.
URL string `mapstructure:"url"`
}
//go:embed static
@@ -379,10 +382,21 @@ func NewServer(userConfig UserConfig, config Config) (*Server, error) {
Event: c.Event,
Kind: c.Kind,
WorkspaceRegex: c.WorkspaceRegex,
URL: c.URL,
}
webhooksConfig = append(webhooksConfig, config)
}
webhooksManager, err := webhooks.NewMultiWebhookSender(webhooksConfig, webhooks.NewSlackClient(userConfig.SlackToken))
webhookHeaders, err := userConfig.ToWebhookHttpHeaders()
if err != nil {
return nil, errors.Wrap(err, "parsing webhook http headers")
}
webhooksManager, err := webhooks.NewMultiWebhookSender(
webhooksConfig,
webhooks.Clients{
Slack: webhooks.NewSlackClient(userConfig.SlackToken),
Http: &webhooks.HttpClient{Client: http.DefaultClient, Headers: webhookHeaders},
},
)
if err != nil {
return nil, errors.Wrap(err, "initializing webhooks")
}

View File

@@ -1,8 +1,11 @@
package server
import (
"encoding/json"
"strings"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/logging"
)
@@ -120,6 +123,7 @@ type UserConfig struct {
DefaultTFDistribution string `mapstructure:"default-tf-distribution"`
DefaultTFVersion string `mapstructure:"default-tf-version"`
Webhooks []WebhookConfig `mapstructure:"webhooks" flag:"false"`
WebhookHttpHeaders string `mapstructure:"webhook-http-headers"`
WebBasicAuth bool `mapstructure:"web-basic-auth"`
WebUsername string `mapstructure:"web-username"`
WebPassword string `mapstructure:"web-password"`
@@ -152,6 +156,37 @@ func (u UserConfig) ToAllowCommandNames() ([]command.Name, error) {
return allowCommands, nil
}
// ToWebhookHttpHeaders parses WebhookHttpHeaders into a map of HTTP headers.
func (u UserConfig) ToWebhookHttpHeaders() (map[string][]string, error) {
if u.WebhookHttpHeaders == "" {
return nil, nil
}
var m map[string]interface{}
err := json.Unmarshal([]byte(u.WebhookHttpHeaders), &m)
if err != nil {
return nil, err
}
headers := make(map[string][]string)
for name, rawValue := range m {
switch val := rawValue.(type) {
case []interface{}:
for _, v := range val {
s, ok := v.(string)
if !ok {
return nil, errors.Errorf("expected string array element, got %T", v)
}
headers[name] = append(headers[name], s)
}
case string:
headers[name] = []string{val}
default:
return nil, errors.Errorf("expected string or array, got %T", val)
}
}
return headers, nil
}
// ToLogLevel returns the LogLevel object corresponding to the user-passed
// log level.
func (u UserConfig) ToLogLevel() logging.LogLevel {

View File

@@ -3,6 +3,8 @@ package server_test
import (
"testing"
"github.com/pkg/errors"
"github.com/runatlantis/atlantis/server"
"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/logging"
@@ -69,6 +71,49 @@ func TestUserConfig_ToAllowCommandNames(t *testing.T) {
}
}
func TestUserConfig_ToWebhookHttpHeaders(t *testing.T) {
tcs := []struct {
name string
given string
want map[string][]string
err error
}{
{
name: "empty",
given: "",
want: nil,
},
{
name: "happy path",
given: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`,
want: map[string][]string{
"Authorization": {"Bearer some-token"},
"X-Custom-Header": {"value1", "value2"},
},
},
{
name: "invalid json",
given: `{"X-Custom-Header":true}`,
err: errors.New("expected string or array, got bool"),
},
{
name: "invalid json array element",
given: `{"X-Custom-Header":[1, 2]}`,
err: errors.New("expected string array element, got float64"),
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
u := server.UserConfig{
WebhookHttpHeaders: tc.given,
}
got, err := u.ToWebhookHttpHeaders()
Equals(t, tc.want, got)
Equals(t, tc.err, err)
})
}
}
func TestUserConfig_ToLogLevel(t *testing.T) {
cases := []struct {
userLvl string