Skip to content

Commit 9bd4c5d

Browse files
feat: add http webhook
Signed-off-by: Piotr Pawluk <[email protected]>
1 parent a19a973 commit 9bd4c5d

14 files changed

+576
-108
lines changed

cmd/server.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ const (
153153
TFELocalExecutionModeFlag = "tfe-local-execution-mode"
154154
TFETokenFlag = "tfe-token"
155155
WriteGitCredsFlag = "write-git-creds" // nolint: gosec
156+
WebhookHttpHeaders = "webhook-http-headers"
156157
WebBasicAuthFlag = "web-basic-auth"
157158
WebUsernameFlag = "web-username"
158159
WebPasswordFlag = "web-password"
@@ -460,6 +461,12 @@ var stringFlags = map[string]stringFlag{
460461
description: "Name used to identify Atlantis for pull request statuses.",
461462
defaultValue: DefaultVCSStatusName,
462463
},
464+
WebhookHttpHeaders: {
465+
description: "Additional headers added to each HTTP POST payload when using HTTP webhooks provided as a JSON string." +
466+
" The map key is the header name and the value is the header value (string) or values (array of string)." +
467+
" For example: `{\"Authorization\":\"Bearer some-token\",\"X-Custom-Header\":[\"value1\",\"value2\"]}`.",
468+
defaultValue: "",
469+
},
463470
WebUsernameFlag: {
464471
description: "Username used for Web Basic Authentication on Atlantis HTTP Middleware",
465472
defaultValue: DefaultWebUsername,
@@ -1069,6 +1076,10 @@ func (s *ServerCmd) validate(userConfig server.UserConfig) error {
10691076
return errors.Wrapf(err, "invalid --%s", AllowCommandsFlag)
10701077
}
10711078

1079+
if _, err := userConfig.ToWebhookHttpHeaders(); err != nil {
1080+
return errors.Wrapf(err, "invalid --%s", WebhookHttpHeaders)
1081+
}
1082+
10721083
return nil
10731084
}
10741085

cmd/server_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ var testFlags = map[string]interface{}{
148148
VarFileAllowlistFlag: "/path",
149149
VCSStatusName: "my-status",
150150
IgnoreVCSStatusNames: "",
151+
WebhookHttpHeaders: `{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}`,
151152
WebBasicAuthFlag: false,
152153
WebPasswordFlag: "atlantis",
153154
WebUsernameFlag: "atlantis",

runatlantis.io/.vitepress/sidebars.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ const en = [
4444
{ text: "Checkout Strategy", link: "/docs/checkout-strategy" },
4545
{ text: "Terraform Versions", link: "/docs/terraform-versions" },
4646
{ text: "Terraform Cloud", link: "/docs/terraform-cloud" },
47-
{ text: "Using Slack Hooks", link: "/docs/using-slack-hooks" },
47+
{ text: "Sending Notifications via Webhooks", link: "/docs/sending-notifications-via-webhooks" },
4848
{ text: "Stats", link: "/docs/stats" },
4949
{ text: "FAQ", link: "/docs/faq" },
5050
]
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
# Sending notifications via webhooks
2+
3+
It is possible to send notifications to external systems whenever an apply is being done.
4+
5+
You can make requests to any HTTP endpoint or send messages directly to your Slack channel.
6+
7+
::: tip NOTE
8+
Currently only `apply` events are supported.
9+
:::
10+
11+
## Configuration
12+
13+
Webhooks are configured in Atlantis [server-side configuration](server-configuration.md).
14+
There can be many webhooks: sending notifications to different destinations or for different
15+
workspaces/branches. Here is example configuration to send Slack messages for every apply:
16+
17+
```yaml
18+
webhooks:
19+
- event: apply
20+
kind: slack
21+
channel: my-channel-id
22+
```
23+
24+
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):
25+
26+
```yaml
27+
## Use Server Side Config,
28+
## ref: https://www.runatlantis.io/docs/server-configuration.html
29+
config: |
30+
---
31+
webhooks:
32+
- event: apply
33+
kind: slack
34+
channel: my-channel-id
35+
```
36+
37+
### Filter on workspace/branch
38+
39+
To limit notifications to particular workspaces or branches, use `workspace-regex` or `branch-regex` parameters.
40+
If the workspace **and** branch matches respective regex, an event will be sent. Note that empty regular expression
41+
(a result of unset parameter) matches every string.
42+
43+
## Using HTTP webhooks
44+
45+
You can send POST requests with JSON payload to any HTTP/HTTPS server.
46+
47+
### Configuring Atlantis
48+
49+
In your Atlantis [server-side configuration](server-configuration.md) you can add the following:
50+
51+
```yaml
52+
webhooks:
53+
- event: apply
54+
kind: http
55+
url: https://example.com/hooks
56+
```
57+
58+
The `apply` event information will be POSTed to `https://example.com/hooks`.
59+
60+
You can supply any additional headers with `--webhook-http-headers` parameter (or environment variable),
61+
for example for authentication purposes. See [webhook-http-headers](server-configuration.md#webhook-http-headers) for details.
62+
63+
### JSON payload
64+
65+
The payload is a JSON-marshalled [ApplyResult](https://pkg.go.dev/github.com/runatlantis/atlantis/server/events/webhooks#ApplyResult) struct.
66+
67+
Example payload:
68+
69+
```json
70+
{
71+
"Workspace": "default",
72+
"Repo": {
73+
"FullName": "octocat/Hello-World",
74+
"Owner": "octocat",
75+
"Name": "Hello-World",
76+
"CloneURL": "https://:@github.com/octocat/Hello-World.git",
77+
"SanitizedCloneURL": "https://:<redacted>@github.com/octocat/Hello-World.git",
78+
"VCSHost": {
79+
"Hostname": "github.com",
80+
"Type": 0
81+
}
82+
},
83+
"Pull": {
84+
"Num": 2137,
85+
"HeadCommit": "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d",
86+
"URL": "https://github.com/octocat/Hello-World/pull/2137",
87+
"HeadBranch": "feature/some-branch",
88+
"BaseBranch": "main",
89+
"Author": "octocat",
90+
"State": 0,
91+
"BaseRepo": {
92+
"FullName": "octocat/Hello-World",
93+
"Owner": "octocat",
94+
"Name": "Hello-World",
95+
"CloneURL": "https://:@github.com/octocat/Hello-World.git",
96+
"SanitizedCloneURL": "https://:<redacted>@github.com/octocat/Hello-World.git",
97+
"VCSHost": {
98+
"Hostname": "github.com",
99+
"Type": 0
100+
}
101+
}
102+
},
103+
"User": {
104+
"Username": "octocat",
105+
"Teams": null
106+
},
107+
"Success": true,
108+
"Directory": "terraform/example",
109+
"ProjectName": "example-project"
110+
}
111+
```
112+
113+
## Using Slack hooks
114+
115+
For this you'll need to:
116+
117+
* Create a Bot user in Slack
118+
* Configure Atlantis to send notifications to Slack.
119+
120+
### Configuring Slack for Atlantis
121+
122+
* Go to [Slack: Apps](https://api.slack.com/apps)
123+
* Click the `Create New App` button
124+
* Select `From scratch` in the dialog that opens
125+
* Give it a name, e.g. `atlantis-bot`.
126+
* Select your Slack workspace
127+
* Click `Create App`
128+
* On the left go to `oAuth & Permissions`
129+
* Scroll down to Scopes | Bot Token Scopes and add the following OAuth scopes:
130+
* `channels:read`
131+
* `chat:write`
132+
* `groups:read`
133+
* `incoming-webhook`
134+
* `mpim:read`
135+
* Install the app onto your Slack workspace
136+
* 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`.
137+
* Create a channel in your Slack workspace (e.g. `my-channel`) or use existing
138+
* Add the app to Created channel or existing channel ( click channel name then tab integrations, there Click "Add apps"
139+
140+
### Configuring Atlantis
141+
142+
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.
143+
144+
In your Atlantis [server-side configuration](server-configuration.md) you can now add the following:
145+
146+
```yaml
147+
webhooks:
148+
- event: apply
149+
kind: slack
150+
channel: my-channel-id
151+
```

runatlantis.io/docs/server-configuration.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1233,7 +1233,7 @@ This is useful when you have many projects and want to keep the pull request cle
12331233
ATLANTIS_SLACK_TOKEN='token'
12341234
```
12351235

1236-
API token for Slack notifications. See [Using Slack hooks](using-slack-hooks.md).
1236+
API token for Slack notifications. See [Using Slack hooks](sending-notifications-via-webhooks.md#using-slack-hooks).
12371237

12381238
### `--ssl-cert-file`
12391239

@@ -1404,6 +1404,18 @@ The effect of the race condition is more evident when using parallel configurati
14041404

14051405
Username used for Basic Authentication on the Atlantis web service. Defaults to `atlantis`.
14061406

1407+
### `--webhook-http-headers`
1408+
1409+
```bash
1410+
atlantis server --webhook-http-headers='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}'
1411+
# or
1412+
ATLANTIS_WEBHOOK_HTTP_HEADERS='{"Authorization":"Bearer some-token","X-Custom-Header":["value1","value2"]}'
1413+
```
1414+
1415+
Additional headers added to each HTTP POST payload when using [http webhooks](sending-notifications-via-webhooks.md#using-http-webhooks)
1416+
provided as a JSON string. The map key is the header name and the value is the header value
1417+
(string) or values (array of string).
1418+
14071419
### `--websocket-check-origin`
14081420

14091421
```bash

runatlantis.io/docs/using-slack-hooks.md

Lines changed: 0 additions & 64 deletions
This file was deleted.

server/events/project_command_runner.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -660,12 +660,13 @@ func (p *DefaultProjectCommandRunner) doApply(ctx command.ProjectContext) (apply
660660
outputs, err := p.runSteps(ctx.Steps, ctx, absPath)
661661

662662
p.Webhooks.Send(ctx.Log, webhooks.ApplyResult{ // nolint: errcheck
663-
Workspace: ctx.Workspace,
664-
User: ctx.User,
665-
Repo: ctx.Pull.BaseRepo,
666-
Pull: ctx.Pull,
667-
Success: err == nil,
668-
Directory: ctx.RepoRelDir,
663+
Workspace: ctx.Workspace,
664+
User: ctx.User,
665+
Repo: ctx.Pull.BaseRepo,
666+
Pull: ctx.Pull,
667+
Success: err == nil,
668+
Directory: ctx.RepoRelDir,
669+
ProjectName: ctx.ProjectName,
669670
})
670671

671672
if err != nil {

server/events/webhooks/http.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package webhooks
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"regexp"
10+
11+
"github.com/pkg/errors"
12+
"github.com/runatlantis/atlantis/server/logging"
13+
)
14+
15+
// HttpWebhook sends webhooks to any HTTP destination.
16+
type HttpWebhook struct {
17+
Client *http.Client
18+
WorkspaceRegex *regexp.Regexp
19+
BranchRegex *regexp.Regexp
20+
URL string
21+
}
22+
23+
// Send sends the webhook to URL if workspace and branch matches their respective regex.
24+
func (h *HttpWebhook) Send(log logging.SimpleLogging, applyResult ApplyResult) error {
25+
if !h.WorkspaceRegex.MatchString(applyResult.Workspace) || !h.BranchRegex.MatchString(applyResult.Pull.BaseBranch) {
26+
return nil
27+
}
28+
if err := h.doSend(log, applyResult); err != nil {
29+
return errors.Wrap(err, fmt.Sprintf("sending webhook to %q", h.URL))
30+
}
31+
return nil
32+
}
33+
34+
func (h *HttpWebhook) doSend(_ logging.SimpleLogging, applyResult ApplyResult) error {
35+
body, err := json.Marshal(applyResult)
36+
if err != nil {
37+
return err
38+
}
39+
req, err := http.NewRequest("POST", h.URL, bytes.NewBuffer(body))
40+
if err != nil {
41+
return err
42+
}
43+
req.Header.Set("Content-Type", "application/json")
44+
resp, err := h.Client.Do(req)
45+
if err != nil {
46+
return err
47+
}
48+
defer resp.Body.Close()
49+
if resp.StatusCode != http.StatusOK {
50+
respBody, _ := io.ReadAll(resp.Body)
51+
return fmt.Errorf("returned status code %d with response %q", resp.StatusCode, respBody)
52+
}
53+
return nil
54+
}
55+
56+
// NewHttpClient creates a new HTTP client that will add arbitrary headers to every request.
57+
func NewHttpClient(headers map[string][]string) *http.Client {
58+
return &http.Client{
59+
Transport: &AuthedTransport{
60+
Base: http.DefaultTransport,
61+
Headers: headers,
62+
},
63+
}
64+
}
65+
66+
// AuthedTransport is a http.RoundTripper which wraps Base
67+
// adding arbitrary Headers to each request.
68+
type AuthedTransport struct {
69+
Base http.RoundTripper
70+
Headers map[string][]string
71+
}
72+
73+
// RoundTrip handles each http request.
74+
func (t *AuthedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
75+
for header, values := range t.Headers {
76+
for _, value := range values {
77+
req.Header.Add(header, value)
78+
}
79+
}
80+
return t.Base.RoundTrip(req)
81+
}

0 commit comments

Comments
 (0)