diff --git a/cmd/server.go b/cmd/server.go index aa8581e705..b71c5b2da3 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -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 } diff --git a/cmd/server_test.go b/cmd/server_test.go index 7d7c1b52d5..6c186c97c9 100644 --- a/cmd/server_test.go +++ b/cmd/server_test.go @@ -148,6 +148,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", diff --git a/runatlantis.io/.vitepress/sidebars.ts b/runatlantis.io/.vitepress/sidebars.ts index 5bcabfc4bc..9afc20f780 100644 --- a/runatlantis.io/.vitepress/sidebars.ts +++ b/runatlantis.io/.vitepress/sidebars.ts @@ -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" }, ] diff --git a/runatlantis.io/docs/sending-notifications-via-webhooks.md b/runatlantis.io/docs/sending-notifications-via-webhooks.md new file mode 100644 index 0000000000..9272a25c89 --- /dev/null +++ b/runatlantis.io/docs/sending-notifications-via-webhooks.md @@ -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://:@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://:@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 +``` diff --git a/runatlantis.io/docs/server-configuration.md b/runatlantis.io/docs/server-configuration.md index cf290dc5ca..9a28a82eb4 100644 --- a/runatlantis.io/docs/server-configuration.md +++ b/runatlantis.io/docs/server-configuration.md @@ -1233,7 +1233,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` @@ -1404,6 +1404,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 diff --git a/runatlantis.io/docs/using-slack-hooks.md b/runatlantis.io/docs/using-slack-hooks.md deleted file mode 100644 index 572b0857f8..0000000000 --- a/runatlantis.io/docs/using-slack-hooks.md +++ /dev/null @@ -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. diff --git a/server/events/project_command_runner.go b/server/events/project_command_runner.go index 76f9ba9202..79e1d7899c 100644 --- a/server/events/project_command_runner.go +++ b/server/events/project_command_runner.go @@ -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 { diff --git a/server/events/webhooks/http.go b/server/events/webhooks/http.go new file mode 100644 index 0000000000..08c07815f7 --- /dev/null +++ b/server/events/webhooks/http.go @@ -0,0 +1,81 @@ +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 *http.Client + 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(log logging.SimpleLogging, applyResult ApplyResult) error { + if !h.WorkspaceRegex.MatchString(applyResult.Workspace) || !h.BranchRegex.MatchString(applyResult.Pull.BaseBranch) { + return nil + } + if err := h.doSend(log, applyResult); err != nil { + return errors.Wrap(err, fmt.Sprintf("sending webhook to %q", h.URL)) + } + return nil +} + +func (h *HttpWebhook) doSend(_ logging.SimpleLogging, 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") + resp, err := h.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 +} + +// NewHttpClient creates a new HTTP client that will add arbitrary headers to every request. +func NewHttpClient(headers map[string][]string) *http.Client { + return &http.Client{ + Transport: &AuthedTransport{ + Base: http.DefaultTransport, + Headers: headers, + }, + } +} + +// AuthedTransport is a http.RoundTripper which wraps Base +// adding arbitrary Headers to each request. +type AuthedTransport struct { + Base http.RoundTripper + Headers map[string][]string +} + +// RoundTrip handles each http request. +func (t *AuthedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + for header, values := range t.Headers { + for _, value := range values { + req.Header.Add(header, value) + } + } + return t.Base.RoundTrip(req) +} diff --git a/server/events/webhooks/http_test.go b/server/events/webhooks/http_test.go new file mode 100644 index 0000000000..37764b94ea --- /dev/null +++ b/server/events/webhooks/http_test.go @@ -0,0 +1,145 @@ +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.NewHttpClient(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.NewHttpClient(nil), + URL: server.URL, + WorkspaceRegex: regexp.MustCompile(".*"), + BranchRegex: regexp.MustCompile(".*"), + } + + err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult) + Ok(t, err) +} + +func TestHttpWebhookDefaultClient(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: 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.NewHttpClient(nil), + 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.NewHttpClient(nil), + URL: server.URL, + WorkspaceRegex: tc.wr, + BranchRegex: tc.br, + } + err := webhook.Send(logging.NewNoopLogger(t), httpApplyResult) + Ok(t, err) + }) + } +} diff --git a/server/events/webhooks/webhooks.go b/server/events/webhooks/webhooks.go index c4b43239a7..acc9f0036b 100644 --- a/server/events/webhooks/webhooks.go +++ b/server/events/webhooks/webhooks.go @@ -15,6 +15,7 @@ package webhooks import ( "fmt" + "net/http" "regexp" "errors" @@ -24,6 +25,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 +38,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 +58,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 *http.Client +} + +func NewMultiWebhookSender(configs []Config, clients Clients) (*MultiWebhookSender, error) { var webhooks []Sender for _, c := range configs { wr, err := regexp.Compile(c.WorkspaceRegex) @@ -76,19 +85,34 @@ 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\"") + } + httpClient := http.DefaultClient + if clients.Http != nil { + httpClient = clients.Http + } + httpWebhook := &HttpWebhook{ + Client: httpClient, + 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 +125,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 diff --git a/server/events/webhooks/webhooks_test.go b/server/events/webhooks/webhooks_test.go index 5ee00bf599..bea20510f2 100644 --- a/server/events/webhooks/webhooks_test.go +++ b/server/events/webhooks/webhooks_test.go @@ -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: 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: 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 } diff --git a/server/server.go b/server/server.go index a77eeddaf8..d9b05dc68f 100644 --- a/server/server.go +++ b/server/server.go @@ -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.NewHttpClient(webhookHeaders), + }, + ) if err != nil { return nil, errors.Wrap(err, "initializing webhooks") } diff --git a/server/user_config.go b/server/user_config.go index 9cd4f54675..3f27aa323d 100644 --- a/server/user_config.go +++ b/server/user_config.go @@ -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 { diff --git a/server/user_config_test.go b/server/user_config_test.go index 225049f335..b37f04cf8b 100644 --- a/server/user_config_test.go +++ b/server/user_config_test.go @@ -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