-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
feat: add http webhook #5233
Changes from 4 commits
9bd4c5d
bed4599
37dc5f4
1834479
1d76666
5939aad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
``` |
This file was deleted.
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 { | ||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. https://pkg.go.dev/net/http#RoundTripper says
As an alternative you could always pass the headers into the webhook itself then set them on doSend() There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
If it ends up being better to do this via a RoundTripper, I wonder if this might be a better approach? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
} |
There was a problem hiding this comment.
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. unlikeSend()
) you should be able to omitlogging.SimpleLogging
altogether instead of passing it in and not using it.(Though this does mean you'll have to make logging.SimpleLogging
_
inSend()
)