mirror of
https://git.vectorsigma.ru/public/atlantis.git
synced 2026-07-28 18:38:29 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
151
runatlantis.io/docs/sending-notifications-via-webhooks.md
Normal file
151
runatlantis.io/docs/sending-notifications-via-webhooks.md
Normal 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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
65
server/events/webhooks/http.go
Normal file
65
server/events/webhooks/http.go
Normal 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
|
||||
}
|
||||
127
server/events/webhooks/http_test.go
Normal file
127
server/events/webhooks/http_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user