Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add http webhook #5233

Merged
merged 6 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}

Expand Down
1 change: 1 addition & 0 deletions cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion runatlantis.io/.vitepress/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand Down
151 changes: 151 additions & 0 deletions runatlantis.io/docs/sending-notifications-via-webhooks.md
Original file line number Diff line number Diff line change
@@ -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
```
14 changes: 13 additions & 1 deletion runatlantis.io/docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1234,7 +1234,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`

Expand Down Expand Up @@ -1405,6 +1405,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
Expand Down
64 changes: 0 additions & 64 deletions runatlantis.io/docs/using-slack-hooks.md

This file was deleted.

13 changes: 7 additions & 6 deletions server/events/project_command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions server/events/webhooks/http.go
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since doSend() is a non-exported method (i.e. unlike Send()) you should be able to omit logging.SimpleLogging altogether instead of passing it in and not using it.

(Though this does mean you'll have to make logging.SimpleLogging _ in Send())

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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

https://pkg.go.dev/net/http#RoundTripper says

RoundTrip should not modify the request, except for consuming and closing the Request's Body
I'm not very familiar with the http package, so I'm not sure what the implications are if the request is modified (or if adding headers qualifies as "modifying"), any thoughts here?

As an alternative you could always pass the headers into the webhook itself then set them on doSend()

Copy link
Contributor Author

@zendesk-piotrpawluk zendesk-piotrpawluk Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not very familiar with the http package, so I'm not sure what the implications are if the request is modified (or if adding headers qualifies as "modifying"), any thoughts here?

The oauth package does the same pattern for authorization and I initially planned to follow (even forgot to change name) but maybe it's an overkill; adding headers explicitly will be less confusing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, as long as it doesn't complicate doSend() too much it might be worth it.

Also looking at that example, it looks like it actually makes a clone of the request, I assume to comply w the part of the doc I quoted above?

	req2 := cloneRequest(req) // per RoundTripper contract

If it ends up being better to do this via a RoundTripper, I wonder if this might be a better approach?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I just went with adding headers explicitly in 5939aad

for header, values := range t.Headers {
for _, value := range values {
req.Header.Add(header, value)
}
}
return t.Base.RoundTrip(req)
}
Loading