├── .github
├── dependabot.yml
└── workflows
│ └── ci.yml
├── Dockerfile
├── LICENSE
├── README.md
├── check
└── main.go
├── concourse
├── build.go
├── build_test.go
├── client.go
├── client_test.go
└── resource.go
├── go.mod
├── go.sum
├── img
├── aborted.png
├── broke.png
├── default.png
├── errored.png
├── failed.png
├── fixed.png
├── started.png
└── success.png
├── in
└── main.go
├── out
├── alert.go
├── alert_test.go
├── main.go
└── main_test.go
└── slack
├── slack.go
└── slack_test.go
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | - package-ecosystem: gomod
8 | directory: "/"
9 | schedule:
10 | interval: daily
11 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | pull_request:
5 | push:
6 | branches:
7 | - main
8 | tags:
9 | - v*
10 |
11 | jobs:
12 | test:
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: actions/setup-go@v5
18 | with:
19 | go-version-file: go.mod
20 | - run: go mod download
21 | - run: go test -cpu=1,2 -race ./...
22 |
23 | image:
24 | needs: [test]
25 | runs-on: ubuntu-latest
26 |
27 | steps:
28 | - uses: actions/checkout@v4
29 | - uses: docker/setup-buildx-action@v3
30 | - uses: docker/setup-qemu-action@v3
31 |
32 | - uses: docker/metadata-action@v5
33 | id: docker_meta
34 | with:
35 | images: docker.io/arbourd/concourse-slack-alert-resource,ghcr.io/arbourd/concourse-slack-alert-resource
36 | tags: |
37 | type=edge
38 | type=semver,pattern={{raw}}
39 | flavor: |
40 | latest=auto
41 |
42 | - name: Build test image
43 | uses: docker/build-push-action@v6
44 | with:
45 | cache-from: type=gha
46 | load: true
47 | push: false
48 | tags: concourse-slack-alert-resource:dev
49 | - run: >
50 | echo "{\"source\":{\"url\":\"${{ vars.SLACK_WEBHOOK }}\"}}" | docker run -i
51 | -e "BUILD_TEAM_NAME=main"
52 | -e "BUILD_PIPELINE_NAME=github-actions"
53 | -e "BUILD_JOB_NAME=test"
54 | -e "BUILD_NAME=$GITHUB_RUN_ID-$GITHUB_RUN_NUMBER"
55 | -e "BUILD_PIPELINE_INSTANCE_VARS={\"ref\":\"$GITHUB_REF_NAME\"}"
56 | concourse-slack-alert-resource:dev
57 | /opt/resource/out $PWD
58 |
59 | - if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main'
60 | uses: docker/login-action@v3
61 | with:
62 | registry: docker.io
63 | username: arbourd
64 | password: ${{ secrets.DOCKER_TOKEN }}
65 | - if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main'
66 | uses: docker/login-action@v3
67 | with:
68 | registry: ghcr.io
69 | username: ${{ github.actor }}
70 | password: ${{ secrets.GITHUB_TOKEN }}
71 |
72 | - name: Build and publish image
73 | if: startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main'
74 | uses: docker/build-push-action@v6
75 | with:
76 | cache-from: type=gha
77 | cache-to: type=gha,mode=max
78 | platforms: linux/amd64,linux/arm64
79 | push: true
80 | tags: ${{ steps.docker_meta.outputs.tags }}
81 | labels: ${{ steps.docker_meta.outputs.labels }}
82 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.24-alpine AS build
2 | WORKDIR /go/src/github.com/arbourd/concourse-slack-alert-resource
3 | RUN apk --no-cache add --update git
4 |
5 | COPY go.* ./
6 | RUN go mod download
7 |
8 | COPY . ./
9 | RUN go build -o /check github.com/arbourd/concourse-slack-alert-resource/check
10 | RUN go build -o /in github.com/arbourd/concourse-slack-alert-resource/in
11 | RUN go build -o /out github.com/arbourd/concourse-slack-alert-resource/out
12 |
13 | FROM alpine:3.19
14 | RUN apk add --no-cache ca-certificates
15 |
16 | COPY --from=build /check /opt/resource/check
17 | COPY --from=build /in /opt/resource/in
18 | COPY --from=build /out /opt/resource/out
19 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Dylan Arbour
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://goreportcard.com/report/github.com/arbourd/concourse-slack-alert-resource)
2 |
3 | # concourse-slack-alert-resource
4 |
5 | A structured and opinionated Slack notification resource for [Concourse](https://concourse-ci.org/).
6 |
7 |
8 |
9 | The message is built by using Concourse's [resource metadata](https://concourse-ci.org/implementing-resource-types.html#resource-metadata) to show the pipeline, job, build number and a URL.
10 |
11 | ## Installing
12 |
13 | Use this resource by adding the following to the resource_types section of a pipeline config:
14 |
15 | ```yaml
16 | resource_types:
17 |
18 | - name: slack-alert
19 | type: registry-image
20 | source:
21 | repository: ghcr.io/arbourd/concourse-slack-alert-resource
22 | ```
23 |
24 | See the [Concourse docs](https://concourse-ci.org/resource-types.html) for more details on adding `resource_types` to a pipeline config.
25 |
26 | ## Source Configuration
27 |
28 | * `url`: *Required.* Slack webhook URL.
29 | * `channel`: *Optional*. Target channel where messages are posted. If unset the default channel of the webhook is used.
30 | * `concourse_url`: *Optional.* The external URL that points to Concourse. Defaults to the env variable `ATC_EXTERNAL_URL`.
31 | * `username`: *Optional.* Concourse local user (or basic auth) username. Required for non-public pipelines if using alert type `fixed` or `broke`
32 | * `password`: *Optional.* Concourse local user (or basic auth) password. Required for non-public pipelines if using alert type `fixed` or `broke`
33 | * `disable`: *Optional.* Disables the resource (does not send notifications). Defaults to `false`.
34 |
35 | ## Behavior
36 |
37 | ### `check`: No operation.
38 |
39 | ### `in`: No operation.
40 |
41 | ### `out`: Send a message to Slack.
42 |
43 | Sends a structured message to Slack based on the alert type.
44 |
45 | #### Parameters
46 |
47 | - `alert_type`: *Optional.* The type of alert to send to Slack. See [Alert Types](#alert-types). Defaults to `default`.
48 | - `channel`: *Optional.* Channel where this message is posted. Defaults to the `channel` setting in Source.
49 | - `channel_file`: *Optional.* File containing text which overrides `channel`. If the file cannot be read, `channel` will be used instead.
50 | - `message`: *Optional.* The status message at the top of the alert. Defaults to name of alert type.
51 | - `message_file`: *Optional.* File containing text which overrides `message`. If the file cannot be read, `message` will be used instead.
52 | - `text`: *Optional.* Additional text below the message of the alert. Defaults to an empty string.
53 | - `text_file`: *Optional.* File containing text which overrides `text`. If the file cannot be read, `text` will be used instead.
54 | - `color`: *Optional.* The color of the notification bar as a hexadecimal. Defaults to the icon color of the alert type.
55 | - `disable`: *Optional.* Disables the alert. Defaults to `false`.
56 |
57 | #### Alert Types
58 |
59 | - `default`
60 |
61 |
62 |
63 | - `success`
64 |
65 |
66 |
67 | - `failed`
68 |
69 |
70 |
71 | - `started`
72 |
73 |
74 |
75 | - `aborted`
76 |
77 |
78 |
79 | - `errored`
80 |
81 |
82 |
83 | - `fixed`
84 |
85 | Fixed is a special alert type that only alerts if the previous build did not succeed. Fixed requires `username` and `password` to be set for the resource if the pipeline is not public.
86 |
87 |
88 |
89 | - `broke`
90 |
91 | Broke is a special alert type that only alerts if the previous build succeed. Broke requires `username` and `password` to be set for the resource if the pipeline is not public.
92 |
93 |
94 |
95 | ## Examples
96 |
97 | ### Out
98 |
99 | Using the default alert type with custom message and color:
100 |
101 | ```yaml
102 | resources:
103 | - name: notify
104 | type: slack-alert
105 | source:
106 | url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
107 |
108 | jobs:
109 | # ...
110 | plan:
111 | - put: notify
112 | params:
113 | message: Completed
114 | color: "#eeeeee"
115 | ```
116 |
117 | Using built-in alert types with appropriate build hooks:
118 |
119 | ```yaml
120 | resources:
121 | - name: notify
122 | type: slack-alert
123 | source:
124 | url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
125 |
126 | jobs:
127 | # ...
128 | plan:
129 | - put: notify
130 | params:
131 | alert_type: started
132 | - put: some-other-task
133 | on_success:
134 | put: notify
135 | params:
136 | alert_type: success
137 | on_failure:
138 | put: notify
139 | params:
140 | alert_type: failed
141 | on_abort:
142 | put: notify
143 | params:
144 | alert_type: aborted
145 | on_error:
146 | put: notify
147 | params:
148 | alert_type: errored
149 | ```
150 |
151 | Using the `fixed` alert type:
152 |
153 | ```yaml
154 | resources:
155 | - name: notify
156 | type: slack-alert
157 | source:
158 | url: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX
159 | # `alert_type: fixed` requires Concourse credentials if pipeline is private
160 | username: concourse
161 | password: concourse
162 |
163 | jobs:
164 | # ...
165 | plan:
166 | - put: some-other-task
167 | on_success:
168 | put: notify
169 | params:
170 | # will only alert if build was successful and fixed
171 | alert_type: fixed
172 | ```
173 |
--------------------------------------------------------------------------------
/check/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/arbourd/concourse-slack-alert-resource/concourse"
10 | )
11 |
12 | func main() {
13 | err := json.NewEncoder(os.Stdout).Encode(concourse.CheckResponse{})
14 | if err != nil {
15 | log.Fatalln(fmt.Errorf("error: %s", err))
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/concourse/build.go:
--------------------------------------------------------------------------------
1 | package concourse
2 |
3 | import (
4 | "fmt"
5 | "net/url"
6 | "os"
7 | "strings"
8 | )
9 |
10 | // A Build is a build's data from the undocumented Concourse API.
11 | type Build struct {
12 | ID int `json:"id"`
13 | Team string `json:"team_name"`
14 | Name string `json:"name"`
15 | Status string `json:"status"`
16 | Job string `json:"job_name"`
17 | APIURL string `json:"api_url"`
18 | Pipeline string `json:"pipeline_name"`
19 | InstanceVars map[string]any `json:"pipeline_instance_vars,omitempty"`
20 | StartTime int `json:"start_time"`
21 | EndTime int `json:"end_time"`
22 | }
23 |
24 | // BuildMetadata is the current build's metadata exposed via the environment.
25 | // https://concourse-ci.org/implementing-resources.html#resource-metadata
26 | type BuildMetadata struct {
27 | Host string
28 | ID string
29 | TeamName string
30 | PipelineName string
31 | InstanceVars string
32 | JobName string
33 | BuildName string
34 | URL string
35 | }
36 |
37 | // NewBuildMetadata returns a populated BuildMetadata.
38 | // The default external URL can be overridden by the URL.
39 | func NewBuildMetadata(atcurl string) BuildMetadata {
40 | if atcurl == "" {
41 | atcurl = os.Getenv("ATC_EXTERNAL_URL")
42 | }
43 |
44 | metadata := BuildMetadata{
45 | Host: strings.TrimSuffix(atcurl, "/"),
46 | ID: os.Getenv("BUILD_ID"),
47 | TeamName: os.Getenv("BUILD_TEAM_NAME"),
48 | PipelineName: os.Getenv("BUILD_PIPELINE_NAME"),
49 | JobName: os.Getenv("BUILD_JOB_NAME"),
50 | BuildName: os.Getenv("BUILD_NAME"),
51 | InstanceVars: os.Getenv("BUILD_PIPELINE_INSTANCE_VARS"),
52 | }
53 |
54 | instanceVarsQuery := ""
55 | if metadata.InstanceVars != "" {
56 | instanceVarsQuery = fmt.Sprintf("?vars=%s", url.QueryEscape(metadata.InstanceVars))
57 | }
58 |
59 | // "$HOST/teams/$BUILD_TEAM_NAME/pipelines/$BUILD_PIPELINE_NAME/jobs/$BUILD_JOB_NAME/builds/$BUILD_NAME?var=$BUILD_PIPELINE_INSTANCE_VARS"
60 | metadata.URL = fmt.Sprintf(
61 | "%s/teams/%s/pipelines/%s/jobs/%s/builds/%s%s",
62 | metadata.Host,
63 | url.PathEscape(metadata.TeamName),
64 | url.PathEscape(metadata.PipelineName),
65 | url.PathEscape(metadata.JobName),
66 | url.PathEscape(metadata.BuildName),
67 | instanceVarsQuery,
68 | )
69 |
70 | return metadata
71 | }
72 |
--------------------------------------------------------------------------------
/concourse/build_test.go:
--------------------------------------------------------------------------------
1 | package concourse
2 |
3 | import (
4 | "os"
5 | "testing"
6 |
7 | "github.com/google/go-cmp/cmp"
8 | )
9 |
10 | func TestNewBuildMetadata(t *testing.T) {
11 | env := map[string]string{
12 | "ATC_EXTERNAL_URL": "https://ci.example.com",
13 | "BUILD_TEAM_NAME": "main",
14 | "BUILD_PIPELINE_NAME": "demo",
15 | "BUILD_JOB_NAME": "my test",
16 | "BUILD_NAME": "1",
17 | }
18 |
19 | cases := map[string]struct {
20 | host string
21 | instanceVars string
22 | want BuildMetadata
23 | }{
24 | "environment only": {
25 | want: BuildMetadata{
26 | Host: "https://ci.example.com",
27 | TeamName: "main",
28 | PipelineName: "demo",
29 | InstanceVars: "",
30 | JobName: "my test",
31 | BuildName: "1",
32 | URL: "https://ci.example.com/teams/main/pipelines/demo/jobs/my%20test/builds/1",
33 | },
34 | },
35 | "url override": {
36 | host: "https://example.com",
37 | want: BuildMetadata{
38 | Host: "https://example.com",
39 | TeamName: "main",
40 | PipelineName: "demo",
41 | InstanceVars: "",
42 | JobName: "my test",
43 | BuildName: "1",
44 | URL: "https://example.com/teams/main/pipelines/demo/jobs/my%20test/builds/1",
45 | },
46 | },
47 | "url with instance vars": {
48 | instanceVars: `{"image_name":"my-image","pr_number":1234,"args":["start"]}`,
49 | want: BuildMetadata{
50 | Host: "https://ci.example.com",
51 | TeamName: "main",
52 | PipelineName: "demo",
53 | InstanceVars: `{"image_name":"my-image","pr_number":1234,"args":["start"]}`,
54 | JobName: "my test",
55 | BuildName: "1",
56 | URL: `https://ci.example.com/teams/main/pipelines/demo/jobs/my%20test/builds/1?vars=%7B%22image_name%22%3A%22my-image%22%2C%22pr_number%22%3A1234%2C%22args%22%3A%5B%22start%22%5D%7D`,
57 | },
58 | },
59 | }
60 |
61 | for name, c := range cases {
62 | t.Run(name, func(t *testing.T) {
63 | for k, v := range env {
64 | os.Setenv(k, v)
65 | }
66 | if c.instanceVars != "" {
67 | os.Setenv("BUILD_PIPELINE_INSTANCE_VARS", c.instanceVars)
68 | } else {
69 | os.Unsetenv("BUILD_PIPELINE_INSTANCE_VARS")
70 | }
71 |
72 | metadata := NewBuildMetadata(c.host)
73 | if !cmp.Equal(metadata, c.want) {
74 | t.Fatalf("unexpected BuildMetadata value from GetBuildMetadata:\n\t(GOT): %#v\n\t(WNT): %#v\n\t(DIFF): %v", metadata, c.want, cmp.Diff(metadata, c.want))
75 | }
76 | })
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/concourse/client.go:
--------------------------------------------------------------------------------
1 | package concourse
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "net/http"
9 | "net/http/cookiejar"
10 | "net/url"
11 | "strconv"
12 |
13 | "github.com/Masterminds/semver/v3"
14 | "golang.org/x/oauth2"
15 | )
16 |
17 | // A Client is a Concourse API connection.
18 | type Client struct {
19 | atcurl *url.URL
20 | team string
21 |
22 | conn *http.Client
23 | }
24 |
25 | // Info is version information from the Concourse API.
26 | type Info struct {
27 | ATCVersion string `json:"version"`
28 | WorkerVersion string `json:"worker_version"`
29 | }
30 |
31 | // A Token is a legacy Concourse access token.
32 | type Token struct {
33 | Type string `json:"type"`
34 | Value string `json:"value"`
35 | }
36 |
37 | // NewClient returns an authorized Client (if private) for the Concourse API.
38 | func NewClient(atcurl, team, username, password string) (*Client, error) {
39 | u, err := url.Parse(atcurl)
40 | if err != nil {
41 | return nil, err
42 | }
43 | // This cookie jar implementation never returns an error.
44 | jar, _ := cookiejar.New(nil)
45 |
46 | c := &Client{
47 | atcurl: u,
48 | team: team,
49 |
50 | conn: &http.Client{Jar: jar},
51 | }
52 |
53 | // Return Client early if authorization is not needed.
54 | if username == "" && password == "" {
55 | return c, nil
56 | }
57 |
58 | info, err := c.info()
59 | if err != nil {
60 | return nil, err
61 | }
62 |
63 | legacy, err := semver.NewConstraint("< 4.0.0")
64 | if err != nil {
65 | return nil, err
66 | }
67 |
68 | v, err := semver.NewVersion(info.ATCVersion)
69 | if err != nil {
70 | return nil, err
71 | }
72 |
73 | multiCookie, err := semver.NewConstraint("5.5 - 6.4")
74 | if err != nil {
75 | return nil, err
76 | }
77 |
78 | oldsky, err := semver.NewConstraint("< 6.1.0")
79 | if err != nil {
80 | return nil, err
81 | }
82 |
83 | // Check if target Concourse is less than '4.0.0'.
84 | if legacy.Check(v) {
85 | url := fmt.Sprintf("%s/api/v1/teams/%s/auth/token", c.atcurl, c.team)
86 | err = c.loginLegacy(url, username, password)
87 | return c, err
88 | }
89 |
90 | url := fmt.Sprintf("%s/sky/issuer/token", c.atcurl)
91 | // Support 4.x and 5.x skymarshall calls
92 | if oldsky.Check(v) {
93 | url = fmt.Sprintf("%s/sky/token", c.atcurl)
94 | }
95 |
96 | token, err := c.login(url, username, password)
97 | if err != nil {
98 | return nil, err
99 | }
100 |
101 | // Check if the version supports single cookie access tokens.
102 | // Single cookie is used between versions 4.0.0 - 5.5.0 and 6.5.0 or greater.
103 | if !multiCookie.Check(v) {
104 | err = c.singleCookie(token.TokenType, token.AccessToken)
105 | return c, err
106 | }
107 |
108 | // Check if the version is less than '6.1.0'.
109 | if oldsky.Check(v) {
110 | err = c.splitToken(token.TokenType, token.AccessToken)
111 | return c, err
112 | }
113 |
114 | idToken, ok := token.Extra("id_token").(string)
115 | if !ok {
116 | return c, errors.New("invalid id_token")
117 | }
118 |
119 | err = c.splitToken(token.TokenType, idToken)
120 | return c, err
121 | }
122 |
123 | // info queries Concourse for its version information.
124 | func (c *Client) info() (Info, error) {
125 | u := fmt.Sprintf("%s/api/v1/info", c.atcurl)
126 | var info Info
127 |
128 | r, err := c.conn.Get(u)
129 | if err != nil {
130 | return info, err
131 | }
132 | if r.StatusCode != 200 {
133 | return info, fmt.Errorf("could not get info from Concourse: status code %d", r.StatusCode)
134 | }
135 | json.NewDecoder(r.Body).Decode(&info)
136 |
137 | return info, nil
138 | }
139 |
140 | // singleCookie add the token as a single cookie.
141 | func (c *Client) singleCookie(tokenType, tokenValue string) error {
142 | c.conn.Jar.SetCookies(
143 | c.atcurl,
144 | []*http.Cookie{{
145 | Name: "skymarshal_auth",
146 | Value: fmt.Sprintf("%s %s", tokenType, tokenValue),
147 | }},
148 | )
149 | return nil
150 | }
151 |
152 | // splitToken splits the token across multiple cookies.
153 | func (c *Client) splitToken(tokenType, tokenValue string) error {
154 | const NumCookies = 15
155 | const authCookieName = "skymarshal_auth"
156 | const maxCookieSize = 4000
157 |
158 | tokenStr := fmt.Sprintf("%s %s", tokenType, tokenValue)
159 |
160 | for i := 0; i < NumCookies; i++ {
161 | if len(tokenStr) > maxCookieSize {
162 | c.conn.Jar.SetCookies(
163 | c.atcurl,
164 | []*http.Cookie{{
165 | Name: authCookieName + strconv.Itoa(i),
166 | Value: tokenStr[:maxCookieSize],
167 | }},
168 | )
169 | tokenStr = tokenStr[maxCookieSize:]
170 | } else {
171 | }
172 | c.conn.Jar.SetCookies(
173 | c.atcurl,
174 | []*http.Cookie{{
175 | Name: authCookieName + strconv.Itoa(i),
176 | Value: tokenStr,
177 | }},
178 | )
179 | break
180 | }
181 |
182 | return nil
183 | }
184 |
185 | // login gets an access token from Concourse.
186 | func (c *Client) login(url, username, password string) (*oauth2.Token, error) {
187 | config := oauth2.Config{
188 | ClientID: "fly",
189 | ClientSecret: "Zmx5",
190 | Endpoint: oauth2.Endpoint{TokenURL: url},
191 | Scopes: []string{"openid", "profile", "email", "federated:id", "groups"},
192 | }
193 | ctx := context.WithValue(context.Background(), oauth2.HTTPClient, c.conn)
194 | t, err := config.PasswordCredentialsToken(ctx, username, password)
195 | return t, err
196 | }
197 |
198 | // loginLegacy gets a legacy access token from Concourse.
199 | func (c *Client) loginLegacy(url, username, password string) error {
200 | req, err := http.NewRequest("GET", url, nil)
201 | if err != nil {
202 | return err
203 | }
204 | req.SetBasicAuth(username, password)
205 |
206 | r, err := c.conn.Do(req)
207 | if err != nil {
208 | return err
209 | }
210 | if r.StatusCode != 200 {
211 | return fmt.Errorf("could not log into Concourse: status code %d", r.StatusCode)
212 | }
213 |
214 | var t Token
215 | json.NewDecoder(r.Body).Decode(&t)
216 |
217 | c.conn.Jar.SetCookies(
218 | c.atcurl,
219 | []*http.Cookie{{
220 | Name: "skymarshal_auth",
221 | Value: fmt.Sprintf("%s %s", t.Type, t.Value),
222 | }},
223 | )
224 | return nil
225 | }
226 |
227 | // JobBuild finds and returns a Build from the Concourse API by its
228 | // pipeline name, job name and build name.
229 | func (c *Client) JobBuild(pipeline, job, name, instanceVars string) (*Build, error) {
230 | u := fmt.Sprintf(
231 | "%s/api/v1/teams/%s/pipelines/%s/jobs/%s/builds/%s%s",
232 | c.atcurl,
233 | c.team,
234 | pipeline,
235 | job,
236 | name,
237 | instanceVars,
238 | )
239 |
240 | r, err := c.conn.Get(u)
241 | if err != nil {
242 | return nil, err
243 | }
244 | if r.StatusCode != 200 {
245 | return nil, fmt.Errorf("unexpected status code: %d", r.StatusCode)
246 | }
247 |
248 | var build *Build
249 | json.NewDecoder(r.Body).Decode(&build)
250 | return build, nil
251 | }
252 |
--------------------------------------------------------------------------------
/concourse/client_test.go:
--------------------------------------------------------------------------------
1 | package concourse
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/http/httptest"
8 | "net/url"
9 | "strings"
10 | "testing"
11 |
12 | "github.com/google/go-cmp/cmp"
13 | )
14 |
15 | const tokenType = "Bearer"
16 |
17 | func TestNewClient(t *testing.T) {
18 | cases := map[string]struct {
19 | version string
20 | public bool
21 | username string
22 | password string
23 |
24 | token string
25 | idToken string
26 | err bool
27 | }{
28 | "public": {
29 | version: "6.5.0",
30 | public: true,
31 | },
32 | "legacy auth": {
33 | version: "3.14.2",
34 | username: "admin",
35 | password: "sup3rs3cret1",
36 |
37 | token: "legacy",
38 | },
39 | "legacy skymarshal": {
40 | version: "4.0.0",
41 | username: "admin",
42 | password: "sup3rs3cret1",
43 |
44 | token: "access-token",
45 | },
46 | "multi cookie": {
47 | version: "6.0.0",
48 | username: "admin",
49 | password: "sup3rs3cret1",
50 |
51 | token: "multi-cookie",
52 | },
53 | "skymarshal id token": {
54 | version: "6.1.0",
55 | username: "admin",
56 | password: "sup3rs3cret1",
57 |
58 | token: "new-access-token",
59 | idToken: "id-token",
60 | },
61 | "skymarshal access token": {
62 | version: "6.5.0",
63 | username: "admin",
64 | password: "sup3rs3cret1",
65 |
66 | token: "new-access-token",
67 | },
68 | "missing id token": {
69 | version: "6.1.0",
70 | username: "admin",
71 | password: "sup3rs3cret1",
72 |
73 | token: "new-access-token",
74 | err: true,
75 | },
76 | "unauthorized": {
77 | version: "6.5.0",
78 | username: "admin",
79 | password: "sup3rs3cret1",
80 |
81 | err: true,
82 | },
83 | }
84 |
85 | for name, c := range cases {
86 | info := Info{ATCVersion: c.version}
87 | legacy := Token{Type: tokenType, Value: c.token}
88 | oldsky := map[string]string{"token_type": tokenType, "access_token": c.token}
89 | sky := map[string]string{"token_type": tokenType, "access_token": c.token, "id_token": c.idToken}
90 | if c.idToken == "" {
91 | sky = oldsky
92 | }
93 |
94 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
95 | var resp []byte
96 | switch r.RequestURI {
97 | case "/api/v1/info":
98 | resp, _ = json.Marshal(info)
99 | case "/api/v1/teams/main/auth/token":
100 | resp, _ = json.Marshal(legacy)
101 | case "/sky/token":
102 | resp, _ = json.Marshal(oldsky)
103 | case "/sky/issuer/token":
104 | resp, _ = json.Marshal(sky)
105 | default:
106 | http.Error(w, "", http.StatusUnauthorized)
107 | }
108 |
109 | w.Header().Set("Content-Type", "application/json")
110 | w.Write(resp)
111 | }))
112 |
113 | t.Run(name, func(t *testing.T) {
114 | client, err := NewClient(s.URL, "main", c.username, c.password)
115 | // Test err conditions.
116 | if err != nil && !c.err {
117 | t.Fatalf("unexpected error from NewClient:\n\t(ERR): %s", err)
118 | } else if err == nil && c.err {
119 | t.Fatalf("expected an error from NewClient:\n\t(GOT): nil")
120 | } else if err != nil && c.err {
121 | return
122 | }
123 |
124 | // Test client conditions (if no errors occurred).
125 | if client.atcurl.String() != s.URL {
126 | t.Fatalf("unexpected Client.atcurl from NewClient:\n\t(GOT): %#v\n\t(WNT): %#v", client.atcurl, s.URL)
127 | } else if client.team != "main" {
128 | t.Fatalf("unexpected Client.atcurl from NewClient:\n\t(GOT): %#v\n\t(WNT): %#v", client.team, "main")
129 | } else if c.public {
130 | return
131 | }
132 |
133 | // Test client cookie conditions (if pipeline is not public).
134 | cv := client.conn.Jar.Cookies(client.atcurl)[0].Value
135 | wnt := strings.Join([]string{tokenType, c.token}, " ")
136 | if c.idToken != "" {
137 | wnt = strings.Join([]string{tokenType, c.idToken}, " ")
138 | }
139 | if cv != wnt {
140 | t.Fatalf("unexpected Client.conn cookie from NewClient:\n\t(GOT): %#v\n\t(WNT): %#v", cv, wnt)
141 | }
142 | })
143 | s.Close()
144 | }
145 | }
146 |
147 | func TestJobBuild(t *testing.T) {
148 | cases := map[string]struct {
149 | build *Build
150 | err bool
151 | }{
152 | "basic": {build: &Build{
153 | ID: 1,
154 | Team: "main",
155 | Name: "1",
156 | Status: "succeeded",
157 | Job: "test",
158 | APIURL: "/api/v1/builds/1",
159 | Pipeline: "demo",
160 | }},
161 | "instance vars": {build: &Build{
162 | ID: 1,
163 | Team: "main",
164 | Name: "1",
165 | Status: "succeeded",
166 | Job: "test",
167 | APIURL: "/api/v1/builds/1",
168 | Pipeline: "demo",
169 | InstanceVars: map[string]any{
170 | "image_name": "my-image",
171 | // Go parses numbers as float64 by default
172 | "pr_number": float64(1234),
173 | "args": []any{"start"},
174 | },
175 | }},
176 | "unauthorized": {
177 | build: &Build{},
178 | err: true,
179 | },
180 | }
181 |
182 | for name, c := range cases {
183 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184 | if c.err {
185 | http.Error(w, "", http.StatusUnauthorized)
186 | }
187 | resp, _ := json.Marshal(c.build)
188 | w.Write(resp)
189 | }))
190 | u, _ := url.Parse(s.URL)
191 |
192 | t.Run(name, func(t *testing.T) {
193 | client := &Client{atcurl: u, team: c.build.Team, conn: &http.Client{}}
194 | instanceVarsQuery := ""
195 |
196 | if c.build.InstanceVars != nil {
197 | varsBytes, _ := json.Marshal(c.build.InstanceVars)
198 | instanceVarsQuery = fmt.Sprintf("?vars=%s", url.QueryEscape(string(varsBytes)))
199 | }
200 |
201 | build, err := client.JobBuild(c.build.Pipeline, c.build.Job, c.build.Name, instanceVarsQuery)
202 |
203 | if err != nil && !c.err {
204 | t.Fatalf("unexpected error from JobBuild:\n\t(ERR): %s", err)
205 | } else if err == nil && c.err {
206 | t.Fatalf("expected an error from JobBuild:\n\t(GOT): nil")
207 | } else if !c.err && !cmp.Equal(build, c.build) {
208 | t.Fatalf("unexpected Build from JobBuild:\n\t(GOT): %#v\n\t(WNT): %#v\n\t(DIFF): %v", build, c.build, cmp.Diff(build, c.build))
209 | }
210 | })
211 | s.Close()
212 | }
213 | }
214 |
--------------------------------------------------------------------------------
/concourse/resource.go:
--------------------------------------------------------------------------------
1 | package concourse
2 |
3 | // A Source is the resource's source configuration.
4 | type Source struct {
5 | URL string `json:"url"`
6 | Username string `json:"username"`
7 | Password string `json:"password"`
8 | ConcourseURL string `json:"concourse_url"`
9 | Channel string `json:"channel"`
10 | Disable bool `json:"disable"`
11 | }
12 |
13 | // Metadata are a key-value pair that must be included for in the in and out
14 | // operation responses.
15 | type Metadata struct {
16 | Name string `json:"name,omitempty"`
17 | Value string `json:"value,omitempty"`
18 | }
19 |
20 | // Version is the key-value pair that the resource is checking, getting or putting.
21 | type Version map[string]string
22 |
23 | // CheckResponse is the output for the check operation.
24 | type CheckResponse []Version
25 |
26 | // InResponse is the output for the in operation.
27 | type InResponse struct {
28 | Version Version `json:"version"`
29 | Metadata []Metadata `json:"metadata"`
30 | }
31 |
32 | // OutParams are the parameters that can be configured for the out operation.
33 | type OutParams struct {
34 | AlertType string `json:"alert_type"`
35 | Channel string `json:"channel"`
36 | ChannelFile string `json:"channel_file"`
37 | Color string `json:"color"`
38 | Message string `json:"message"`
39 | MessageFile string `json:"message_file"`
40 | Text string `json:"text"`
41 | TextFile string `json:"text_file"`
42 | Disable bool `json:"disable"`
43 | }
44 |
45 | // OutRequest is in the input for the out operation.
46 | type OutRequest struct {
47 | Source Source `json:"source"`
48 | Params OutParams `json:"params"`
49 | }
50 |
51 | // OutResponse is the output for the out operation.
52 | type OutResponse struct {
53 | Version Version `json:"version"`
54 | Metadata []Metadata `json:"metadata"`
55 | }
56 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/arbourd/concourse-slack-alert-resource
2 |
3 | go 1.24
4 |
5 | require (
6 | github.com/Masterminds/semver/v3 v3.3.1
7 | github.com/cenkalti/backoff/v4 v4.3.0
8 | github.com/google/go-cmp v0.7.0
9 | golang.org/x/oauth2 v0.30.0
10 | )
11 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
2 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
3 | github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
4 | github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
5 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
7 | golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
8 | golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
9 |
--------------------------------------------------------------------------------
/img/aborted.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/aborted.png
--------------------------------------------------------------------------------
/img/broke.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/broke.png
--------------------------------------------------------------------------------
/img/default.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/default.png
--------------------------------------------------------------------------------
/img/errored.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/errored.png
--------------------------------------------------------------------------------
/img/failed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/failed.png
--------------------------------------------------------------------------------
/img/fixed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/fixed.png
--------------------------------------------------------------------------------
/img/started.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/started.png
--------------------------------------------------------------------------------
/img/success.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arbourd/concourse-slack-alert-resource/8ced7e0676b7ac682246a75477f9a58638fddaff/img/success.png
--------------------------------------------------------------------------------
/in/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "os"
8 |
9 | "github.com/arbourd/concourse-slack-alert-resource/concourse"
10 | )
11 |
12 | func main() {
13 | err := json.NewEncoder(os.Stdout).Encode(concourse.InResponse{Version: concourse.Version{"ver": "static"}})
14 | if err != nil {
15 | log.Fatalln(fmt.Errorf("error: %s", err))
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/out/alert.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import "github.com/arbourd/concourse-slack-alert-resource/concourse"
4 |
5 | // An Alert defines the notification that will be sent to Slack.
6 | type Alert struct {
7 | Type string
8 | Channel string
9 | ChannelFile string
10 | Color string
11 | IconURL string
12 | Message string
13 | MessageFile string
14 | Text string
15 | TextFile string
16 | Disabled bool
17 | }
18 |
19 | // NewAlert constructs and returns an Alert.
20 | func NewAlert(input *concourse.OutRequest) Alert {
21 | var alert Alert
22 | switch input.Params.AlertType {
23 | case "success":
24 | alert = Alert{
25 | Type: "success",
26 | Color: "#32cd32",
27 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-succeeded.png",
28 | Message: "Success",
29 | }
30 | case "failed":
31 | alert = Alert{
32 | Type: "failed",
33 | Color: "#d00000",
34 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-failed.png",
35 | Message: "Failed",
36 | }
37 | case "started":
38 | alert = Alert{
39 | Type: "started",
40 | Color: "#f7cd42",
41 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-started.png",
42 | Message: "Started",
43 | }
44 | case "aborted":
45 | alert = Alert{
46 | Type: "aborted",
47 | Color: "#8d4b32",
48 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-aborted.png",
49 | Message: "Aborted",
50 | }
51 | case "fixed":
52 | alert = Alert{
53 | Type: "fixed",
54 | Color: "#32cd32",
55 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-succeeded.png",
56 | Message: "Fixed",
57 | }
58 | case "broke":
59 | alert = Alert{
60 | Type: "broke",
61 | Color: "#d00000",
62 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-failed.png",
63 | Message: "Broke",
64 | }
65 | case "errored":
66 | alert = Alert{
67 | Type: "errored",
68 | Color: "#f5a623",
69 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-errored.png",
70 | Message: "Errored",
71 | }
72 | default:
73 | alert = Alert{
74 | Type: "default",
75 | Color: "#35495c",
76 | IconURL: "https://ci.concourse-ci.org/public/images/favicon-pending.png",
77 | Message: "",
78 | }
79 | }
80 |
81 | alert.Disabled = input.Params.Disable
82 | if alert.Disabled == false {
83 | alert.Disabled = input.Source.Disable
84 | }
85 |
86 | alert.Channel = input.Params.Channel
87 | if alert.Channel == "" {
88 | alert.Channel = input.Source.Channel
89 | }
90 | alert.ChannelFile = input.Params.ChannelFile
91 |
92 | if input.Params.Message != "" {
93 | alert.Message = input.Params.Message
94 | }
95 | alert.MessageFile = input.Params.MessageFile
96 |
97 | if input.Params.Color != "" {
98 | alert.Color = input.Params.Color
99 | }
100 |
101 | alert.Text = input.Params.Text
102 | alert.TextFile = input.Params.TextFile
103 | return alert
104 | }
105 |
--------------------------------------------------------------------------------
/out/alert_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/arbourd/concourse-slack-alert-resource/concourse"
7 | "github.com/google/go-cmp/cmp"
8 | )
9 |
10 | func TestNewAlert(t *testing.T) {
11 | cases := map[string]struct {
12 | input *concourse.OutRequest
13 | want Alert
14 | }{
15 | // Default and overrides.
16 | "default": {
17 | input: &concourse.OutRequest{},
18 | want: Alert{Type: "default", Color: "#35495c", IconURL: "https://ci.concourse-ci.org/public/images/favicon-pending.png"},
19 | },
20 | "custom params": {
21 | input: &concourse.OutRequest{
22 | Source: concourse.Source{Channel: "general"},
23 | Params: concourse.OutParams{Channel: "custom-channel", Color: "#ffffff", Message: "custom-message", Text: "custom-text", Disable: true},
24 | },
25 | want: Alert{Type: "default", Channel: "custom-channel", Color: "#ffffff", IconURL: "https://ci.concourse-ci.org/public/images/favicon-pending.png", Message: "custom-message", Text: "custom-text", Disabled: true},
26 | },
27 | "custom source": {
28 | input: &concourse.OutRequest{
29 | Source: concourse.Source{Channel: "general", Disable: true},
30 | },
31 | want: Alert{Type: "default", Channel: "general", Color: "#35495c", IconURL: "https://ci.concourse-ci.org/public/images/favicon-pending.png", Disabled: true},
32 | },
33 |
34 | // Alert types.
35 | "success": {
36 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "success"}},
37 | want: Alert{Type: "success", Color: "#32cd32", IconURL: "https://ci.concourse-ci.org/public/images/favicon-succeeded.png", Message: "Success"},
38 | },
39 | "failed": {
40 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "failed"}},
41 | want: Alert{Type: "failed", Color: "#d00000", IconURL: "https://ci.concourse-ci.org/public/images/favicon-failed.png", Message: "Failed"},
42 | },
43 | "started": {
44 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "started"}},
45 | want: Alert{Type: "started", Color: "#f7cd42", IconURL: "https://ci.concourse-ci.org/public/images/favicon-started.png", Message: "Started"},
46 | },
47 | "aborted": {
48 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "aborted"}},
49 | want: Alert{Type: "aborted", Color: "#8d4b32", IconURL: "https://ci.concourse-ci.org/public/images/favicon-aborted.png", Message: "Aborted"},
50 | },
51 | "fixed": {
52 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "fixed"}},
53 | want: Alert{Type: "fixed", Color: "#32cd32", IconURL: "https://ci.concourse-ci.org/public/images/favicon-succeeded.png", Message: "Fixed"},
54 | },
55 | "broke": {
56 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "broke"}},
57 | want: Alert{Type: "broke", Color: "#d00000", IconURL: "https://ci.concourse-ci.org/public/images/favicon-failed.png", Message: "Broke"},
58 | },
59 | "errored": {
60 | input: &concourse.OutRequest{Params: concourse.OutParams{AlertType: "errored"}},
61 | want: Alert{Type: "errored", Color: "#f5a623", IconURL: "https://ci.concourse-ci.org/public/images/favicon-errored.png", Message: "Errored"},
62 | },
63 | }
64 |
65 | for name, c := range cases {
66 | t.Run(name, func(t *testing.T) {
67 | got := NewAlert(c.input)
68 | if !cmp.Equal(got, c.want) {
69 | t.Fatalf("unexpected Alert from NewAlert:\n\t(GOT): %#v\n\t(WNT): %#v\n\t(DIFF): %v", got, c.want, cmp.Diff(got, c.want))
70 | }
71 | })
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/out/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "log"
8 | "os"
9 | "path/filepath"
10 | "strconv"
11 | "strings"
12 | "time"
13 |
14 | "github.com/arbourd/concourse-slack-alert-resource/concourse"
15 | "github.com/arbourd/concourse-slack-alert-resource/slack"
16 | )
17 |
18 | func buildMessage(alert Alert, m concourse.BuildMetadata, path string) *slack.Message {
19 | message := alert.Message
20 | channel := alert.Channel
21 | text := alert.Text
22 |
23 | // Open and read message file if set
24 | if alert.MessageFile != "" {
25 | file := filepath.Join(path, alert.MessageFile)
26 | f, err := os.ReadFile(file)
27 |
28 | if err != nil {
29 | fmt.Fprintf(os.Stderr, "error reading message_file: %v\nwill default to message instead\n", err)
30 | } else {
31 | message = strings.TrimSpace(string(f))
32 | }
33 | }
34 |
35 | // Open and read channel file if set
36 | if alert.ChannelFile != "" {
37 | file := filepath.Join(path, alert.ChannelFile)
38 | f, err := os.ReadFile(file)
39 |
40 | if err != nil {
41 | fmt.Fprintf(os.Stderr, "error reading channel_file: %v\nwill default to channel instead\n", err)
42 | } else {
43 | channel = strings.TrimSpace(string(f))
44 | }
45 | }
46 |
47 | // Open and read text file if set
48 | if alert.TextFile != "" {
49 | file := filepath.Join(path, alert.TextFile)
50 | f, err := os.ReadFile(file)
51 |
52 | if err != nil {
53 | fmt.Fprintf(os.Stderr, "error reading text_file: %v\nwill default to text instead\n", err)
54 | } else {
55 | text = strings.TrimSpace(string(f))
56 | }
57 | }
58 |
59 | attachment := slack.Attachment{
60 | Fallback: fmt.Sprintf("%s -- %s", fmt.Sprintf("%s: %s/%s/%s", message, m.PipelineName, m.JobName, m.BuildName), m.URL),
61 | AuthorName: message,
62 | Color: alert.Color,
63 | Footer: m.URL,
64 | FooterIcon: alert.IconURL,
65 | Fields: []slack.Field{
66 | {
67 | Title: "Job",
68 | Value: fmt.Sprintf("%s/%s", m.PipelineName, m.JobName),
69 | Short: true,
70 | },
71 | {
72 | Title: "Build",
73 | Value: m.BuildName,
74 | Short: true,
75 | },
76 | },
77 | Text: text,
78 | }
79 |
80 | return &slack.Message{Attachments: []slack.Attachment{attachment}, Channel: channel}
81 | }
82 |
83 | func previousBuildStatus(input *concourse.OutRequest, m concourse.BuildMetadata) (string, error) {
84 | // Exit early if first build
85 | if m.BuildName == "1" {
86 | return "", nil
87 | }
88 |
89 | c, err := concourse.NewClient(m.Host, m.TeamName, input.Source.Username, input.Source.Password)
90 | if err != nil {
91 | return "", fmt.Errorf("error connecting to Concourse: %w", err)
92 | }
93 |
94 | p, err := previousBuildName(m.BuildName)
95 | if err != nil {
96 | return "", fmt.Errorf("error parsing build name: %w", err)
97 | }
98 |
99 | instanceVars := ""
100 | instanceVarsIndex := strings.Index(m.URL, "?")
101 | if instanceVarsIndex > -1 {
102 | instanceVars = m.URL[instanceVarsIndex:]
103 | }
104 |
105 | previous, err := c.JobBuild(m.PipelineName, m.JobName, p, instanceVars)
106 | if err != nil {
107 | return "", fmt.Errorf("error requesting Concourse build status: %w", err)
108 | }
109 |
110 | return previous.Status, nil
111 | }
112 |
113 | func previousBuildName(s string) (string, error) {
114 | strs := strings.Split(s, ".")
115 |
116 | if len(strs) == 1 {
117 | i, err := strconv.Atoi(strs[0])
118 | if err != nil {
119 | return "", err
120 | }
121 |
122 | return strconv.Itoa(i - 1), nil
123 | }
124 |
125 | i, err := strconv.Atoi(strs[1])
126 | if err != nil {
127 | return "", err
128 | }
129 |
130 | s = fmt.Sprintf("%s.%s", strs[0], strconv.Itoa(i-1))
131 | return strings.Trim(s, ".0"), nil
132 | }
133 |
134 | var maxElapsedTime = 30 * time.Second
135 |
136 | func out(input *concourse.OutRequest, path string) (*concourse.OutResponse, error) {
137 | if input.Source.URL == "" {
138 | return nil, errors.New("slack webhook url cannot be blank")
139 | }
140 |
141 | alert := NewAlert(input)
142 | metadata := concourse.NewBuildMetadata(input.Source.ConcourseURL)
143 | if alert.Disabled {
144 | return buildOut(alert.Type, alert.Channel, false), nil
145 | }
146 |
147 | if alert.Type == "fixed" || alert.Type == "broke" {
148 | pstatus, err := previousBuildStatus(input, metadata)
149 | if err != nil {
150 | return nil, fmt.Errorf("error getting last build status: %w", err)
151 | }
152 |
153 | if (alert.Type == "fixed" && pstatus == "succeeded") || (alert.Type == "broke" && pstatus != "succeeded") {
154 | return buildOut(alert.Type, alert.Channel, false), nil
155 | }
156 | }
157 |
158 | message := buildMessage(alert, metadata, path)
159 | err := slack.Send(input.Source.URL, message, maxElapsedTime)
160 | if err != nil {
161 | return nil, fmt.Errorf("error sending slack message: %w", err)
162 | }
163 | return buildOut(alert.Type, message.Channel, true), nil
164 | }
165 |
166 | func buildOut(atype string, channel string, alerted bool) *concourse.OutResponse {
167 | return &concourse.OutResponse{
168 | Version: concourse.Version{"ver": "static"},
169 | Metadata: []concourse.Metadata{
170 | {Name: "type", Value: atype},
171 | {Name: "channel", Value: channel},
172 | {Name: "alerted", Value: strconv.FormatBool(alerted)},
173 | },
174 | }
175 | }
176 |
177 | func main() {
178 | // The first argument is the path to the build's sources.
179 | path := os.Args[1]
180 |
181 | var input *concourse.OutRequest
182 | err := json.NewDecoder(os.Stdin).Decode(&input)
183 | if err != nil {
184 | log.Fatalln(fmt.Errorf("error reading stdin: %w", err))
185 | }
186 |
187 | o, err := out(input, path)
188 | if err != nil {
189 | log.Fatalln(err)
190 | }
191 |
192 | err = json.NewEncoder(os.Stdout).Encode(o)
193 | if err != nil {
194 | log.Fatalln(fmt.Errorf("error writing stdout: %w", err))
195 | }
196 | }
197 |
--------------------------------------------------------------------------------
/out/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "os"
7 | "path/filepath"
8 | "testing"
9 |
10 | "github.com/arbourd/concourse-slack-alert-resource/concourse"
11 | "github.com/arbourd/concourse-slack-alert-resource/slack"
12 | "github.com/google/go-cmp/cmp"
13 | )
14 |
15 | func TestOut(t *testing.T) {
16 | ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17 | w.WriteHeader(http.StatusOK)
18 | }))
19 | defer ok.Close()
20 | bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
21 | w.WriteHeader(http.StatusNotFound)
22 | }))
23 | defer bad.Close()
24 |
25 | env := map[string]string{
26 | "ATC_EXTERNAL_URL": "https://ci.example.com",
27 | "BUILD_TEAM_NAME": "main",
28 | "BUILD_PIPELINE_NAME": "demo",
29 | "BUILD_JOB_NAME": "test",
30 | "BUILD_NAME": "2",
31 | }
32 |
33 | cases := map[string]struct {
34 | outRequest *concourse.OutRequest
35 | want *concourse.OutResponse
36 | env map[string]string
37 | err bool
38 | }{
39 | "default alert": {
40 | outRequest: &concourse.OutRequest{
41 | Source: concourse.Source{URL: ok.URL},
42 | },
43 | want: &concourse.OutResponse{
44 | Version: concourse.Version{"ver": "static"},
45 | Metadata: []concourse.Metadata{
46 | {Name: "type", Value: "default"},
47 | {Name: "channel", Value: ""},
48 | {Name: "alerted", Value: "true"},
49 | },
50 | },
51 | env: env,
52 | },
53 | "success alert": {
54 | outRequest: &concourse.OutRequest{
55 | Source: concourse.Source{URL: ok.URL},
56 | Params: concourse.OutParams{AlertType: "success"},
57 | },
58 | want: &concourse.OutResponse{
59 | Version: concourse.Version{"ver": "static"},
60 | Metadata: []concourse.Metadata{
61 | {Name: "type", Value: "success"},
62 | {Name: "channel", Value: ""},
63 | {Name: "alerted", Value: "true"},
64 | },
65 | },
66 | env: env,
67 | },
68 | "failed alert": {
69 | outRequest: &concourse.OutRequest{
70 | Source: concourse.Source{URL: ok.URL},
71 | Params: concourse.OutParams{AlertType: "failed"},
72 | },
73 | want: &concourse.OutResponse{
74 | Version: concourse.Version{"ver": "static"},
75 | Metadata: []concourse.Metadata{
76 | {Name: "type", Value: "failed"},
77 | {Name: "channel", Value: ""},
78 | {Name: "alerted", Value: "true"},
79 | },
80 | },
81 | env: env,
82 | },
83 | "started alert": {
84 | outRequest: &concourse.OutRequest{
85 | Source: concourse.Source{URL: ok.URL},
86 | Params: concourse.OutParams{AlertType: "started"},
87 | },
88 | want: &concourse.OutResponse{
89 | Version: concourse.Version{"ver": "static"},
90 | Metadata: []concourse.Metadata{
91 | {Name: "type", Value: "started"},
92 | {Name: "channel", Value: ""},
93 | {Name: "alerted", Value: "true"},
94 | },
95 | },
96 | env: env,
97 | },
98 | "aborted alert": {
99 | outRequest: &concourse.OutRequest{
100 | Source: concourse.Source{URL: ok.URL},
101 | Params: concourse.OutParams{AlertType: "aborted"},
102 | },
103 | want: &concourse.OutResponse{
104 | Version: concourse.Version{"ver": "static"},
105 | Metadata: []concourse.Metadata{
106 | {Name: "type", Value: "aborted"},
107 | {Name: "channel", Value: ""},
108 | {Name: "alerted", Value: "true"},
109 | },
110 | },
111 | env: env,
112 | },
113 | "custom alert": {
114 | outRequest: &concourse.OutRequest{
115 | Source: concourse.Source{URL: ok.URL},
116 | Params: concourse.OutParams{
117 | AlertType: "non-existent-type",
118 | Message: "Deploying",
119 | Color: "#ffffff",
120 | },
121 | },
122 | want: &concourse.OutResponse{
123 | Version: concourse.Version{"ver": "static"},
124 | Metadata: []concourse.Metadata{
125 | {Name: "type", Value: "default"},
126 | {Name: "channel", Value: ""},
127 | {Name: "alerted", Value: "true"},
128 | },
129 | },
130 | env: env,
131 | },
132 | "override channel at Source": {
133 | outRequest: &concourse.OutRequest{
134 | Source: concourse.Source{URL: ok.URL, Channel: "#source"},
135 | },
136 | want: &concourse.OutResponse{
137 | Version: concourse.Version{"ver": "static"},
138 | Metadata: []concourse.Metadata{
139 | {Name: "type", Value: "default"},
140 | {Name: "channel", Value: "#source"},
141 | {Name: "alerted", Value: "true"},
142 | },
143 | },
144 | env: env,
145 | },
146 | "override channel at Params": {
147 | outRequest: &concourse.OutRequest{
148 | Source: concourse.Source{URL: ok.URL, Channel: "#source"},
149 | Params: concourse.OutParams{Channel: "#params"},
150 | },
151 | want: &concourse.OutResponse{
152 | Version: concourse.Version{"ver": "static"},
153 | Metadata: []concourse.Metadata{
154 | {Name: "type", Value: "default"},
155 | {Name: "channel", Value: "#params"},
156 | {Name: "alerted", Value: "true"},
157 | },
158 | },
159 | env: env,
160 | },
161 | "disable alert": {
162 | outRequest: &concourse.OutRequest{
163 | Source: concourse.Source{URL: bad.URL},
164 | Params: concourse.OutParams{Disable: true},
165 | },
166 | want: &concourse.OutResponse{
167 | Version: concourse.Version{"ver": "static"},
168 | Metadata: []concourse.Metadata{
169 | {Name: "type", Value: "default"},
170 | {Name: "channel", Value: ""},
171 | {Name: "alerted", Value: "false"},
172 | },
173 | },
174 | env: env,
175 | },
176 | "error without Slack URL": {
177 | outRequest: &concourse.OutRequest{
178 | Source: concourse.Source{URL: ""},
179 | },
180 | env: env,
181 | err: true,
182 | },
183 | "error with bad request": {
184 | outRequest: &concourse.OutRequest{
185 | Source: concourse.Source{URL: bad.URL},
186 | },
187 | env: env,
188 | err: true,
189 | },
190 | "error without basic auth for fixed type": {
191 | outRequest: &concourse.OutRequest{
192 | Source: concourse.Source{URL: ok.URL, Username: "", Password: ""},
193 | Params: concourse.OutParams{AlertType: "fixed"},
194 | },
195 | env: env,
196 | err: true,
197 | },
198 | }
199 |
200 | for name, c := range cases {
201 | t.Run(name, func(t *testing.T) {
202 | for k, v := range c.env {
203 | os.Setenv(k, v)
204 | }
205 |
206 | got, err := out(c.outRequest, "")
207 | if err != nil && !c.err {
208 | t.Fatalf("unexpected error from out:\n\t(ERR): %s", err)
209 | } else if err == nil && c.err {
210 | t.Fatalf("expected an error from out:\n\t(GOT): nil")
211 | } else if !cmp.Equal(got, c.want) {
212 | t.Fatalf("unexpected concourse.OutResponse value from out:\n\t(GOT): %#v\n\t(WNT): %#v\n\t(DIFF): %v", got, c.want, cmp.Diff(got, c.want))
213 | }
214 | })
215 | }
216 | }
217 | func TestBuildMessage(t *testing.T) {
218 | cases := map[string]struct {
219 | alert Alert
220 | want *slack.Message
221 | }{
222 | "empty channel": {
223 | alert: Alert{
224 | Type: "default",
225 | Color: "#ffffff",
226 | IconURL: "",
227 | Message: "Testing",
228 | },
229 | want: &slack.Message{
230 | Attachments: []slack.Attachment{
231 | {
232 | Fallback: "Testing: demo/test/1 -- https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
233 | Color: "#ffffff",
234 | AuthorName: "Testing",
235 | Fields: []slack.Field{
236 | {Title: "Job", Value: "demo/test", Short: true},
237 | {Title: "Build", Value: "1", Short: true},
238 | },
239 | Footer: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1", FooterIcon: ""},
240 | },
241 | Channel: ""},
242 | },
243 | "channel and url set": {
244 | alert: Alert{
245 | Type: "default",
246 | Channel: "general",
247 | Color: "#ffffff",
248 | IconURL: "",
249 | Message: "Testing",
250 | },
251 | want: &slack.Message{
252 | Attachments: []slack.Attachment{
253 | {
254 | Fallback: "Testing: demo/test/1 -- https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
255 | Color: "#ffffff",
256 | AuthorName: "Testing",
257 | Fields: []slack.Field{
258 | {Title: "Job", Value: "demo/test", Short: true},
259 | {Title: "Build", Value: "1", Short: true},
260 | },
261 | Footer: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1", FooterIcon: ""},
262 | },
263 | Channel: "general"},
264 | },
265 | "message file": {
266 | alert: Alert{
267 | Type: "default",
268 | Message: "Testing",
269 | MessageFile: "test_file",
270 | },
271 | want: &slack.Message{
272 | Attachments: []slack.Attachment{
273 | {
274 | Fallback: "filecontents: demo/test/1 -- https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
275 | AuthorName: "filecontents",
276 | Fields: []slack.Field{
277 | {Title: "Job", Value: "demo/test", Short: true},
278 | {Title: "Build", Value: "1", Short: true},
279 | },
280 | Footer: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1", FooterIcon: ""},
281 | },
282 | },
283 | },
284 | "message file failure": {
285 | alert: Alert{
286 | Type: "default",
287 | Message: "Testing",
288 | MessageFile: "missing file",
289 | },
290 | want: &slack.Message{
291 | Attachments: []slack.Attachment{
292 | {
293 | Fallback: "Testing: demo/test/1 -- https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
294 | AuthorName: "Testing",
295 | Fields: []slack.Field{
296 | {Title: "Job", Value: "demo/test", Short: true},
297 | {Title: "Build", Value: "1", Short: true},
298 | },
299 | Footer: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1", FooterIcon: ""},
300 | },
301 | },
302 | },
303 | "channel file": {
304 | alert: Alert{
305 | Type: "default",
306 | Channel: "testchannel",
307 | ChannelFile: "test_file",
308 | },
309 | want: &slack.Message{
310 | Attachments: []slack.Attachment{
311 | {
312 | Fallback: ": demo/test/1 -- https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
313 | Fields: []slack.Field{
314 | {Title: "Job", Value: "demo/test", Short: true},
315 | {Title: "Build", Value: "1", Short: true},
316 | },
317 | Footer: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1", FooterIcon: ""},
318 | },
319 | Channel: "filecontents",
320 | },
321 | },
322 | "channel file failure": {
323 | alert: Alert{
324 | Type: "default",
325 | Channel: "testchannel",
326 | ChannelFile: "missing file",
327 | },
328 | want: &slack.Message{
329 | Attachments: []slack.Attachment{
330 | {
331 | Fallback: ": demo/test/1 -- https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
332 | Fields: []slack.Field{
333 | {Title: "Job", Value: "demo/test", Short: true},
334 | {Title: "Build", Value: "1", Short: true},
335 | },
336 | Footer: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1", FooterIcon: ""},
337 | },
338 | Channel: "testchannel",
339 | },
340 | },
341 | }
342 |
343 | metadata := concourse.BuildMetadata{
344 | Host: "https://ci.example.com",
345 | TeamName: "main",
346 | PipelineName: "demo",
347 | JobName: "test",
348 | BuildName: "1",
349 | URL: "https://ci.example.com/teams/main/pipelines/demo/jobs/test/builds/1",
350 | }
351 |
352 | for name, c := range cases {
353 | t.Run(name, func(t *testing.T) {
354 | path := ""
355 | if c.alert.MessageFile != "" || c.alert.ChannelFile != "" {
356 | path = t.TempDir()
357 |
358 | if err := os.WriteFile(filepath.Join(path, "test_file"), []byte("filecontents"), 0666); err != nil {
359 | t.Fatal(err)
360 | }
361 | }
362 |
363 | got := buildMessage(c.alert, metadata, path)
364 | if !cmp.Equal(got, c.want) {
365 | t.Fatalf("unexpected slack.Message value from buildSlackMessage:\n\t(GOT): %#v\n\t(WNT): %#v\n\t(DIFF): %v", got, c.want, cmp.Diff(got, c.want))
366 | }
367 | })
368 | }
369 | }
370 |
371 | func TestPreviousBuildName(t *testing.T) {
372 | cases := map[string]struct {
373 | build string
374 | want string
375 |
376 | err bool
377 | }{
378 | "standard": {
379 | build: "6",
380 | want: "6",
381 | },
382 | "rerun 1": {
383 | build: "6.1",
384 | want: "6",
385 | },
386 | "rerun x": {
387 | build: "6.2",
388 | want: "6.1",
389 | },
390 | "error 1": {
391 | build: "X",
392 | err: true,
393 | },
394 | "error x": {
395 | build: "6.X",
396 | err: true,
397 | },
398 | }
399 |
400 | for name, c := range cases {
401 | t.Run(name, func(t *testing.T) {
402 | got, err := previousBuildName(c.build)
403 | if err != nil && !c.err {
404 | t.Fatalf("unexpected error from previousBuildName:\n\t(ERR): %s", err)
405 | } else if err == nil && c.err {
406 | t.Fatalf("expected an error from previousBuildName:\n\t(GOT): nil")
407 | } else if err != nil && c.err {
408 | return
409 | }
410 |
411 | if err != nil {
412 | t.Fatalf("unexpected value from previousBuildName:\n\t(GOT): %#v\n\t(WNT): %#v", got, c.want)
413 | }
414 | })
415 | }
416 | }
417 |
--------------------------------------------------------------------------------
/slack/slack.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "time"
9 |
10 | "github.com/cenkalti/backoff/v4"
11 | )
12 |
13 | // Message represents a Slack API message
14 | // https://api.slack.com/docs/messages
15 | type Message struct {
16 | Attachments []Attachment `json:"attachments"`
17 | Channel string `json:"channel,omitempty"`
18 | }
19 |
20 | // Attachment represents a Slack API message attachment
21 | // https://api.slack.com/docs/message-attachments
22 | type Attachment struct {
23 | Fallback string `json:"fallback"`
24 | Color string `json:"color"`
25 | AuthorName string `json:"author_name"`
26 | Fields []Field `json:"fields"`
27 | Footer string `json:"footer"`
28 | FooterIcon string `json:"footer_icon"`
29 | Text string `json:"text"`
30 | }
31 |
32 | // Field represents a Slack API message attachment's fields
33 | // https://api.slack.com/docs/message-attachments
34 | type Field struct {
35 | Title string `json:"title"`
36 | Value string `json:"value"`
37 | Short bool `json:"short"`
38 | }
39 |
40 | // Send sends the message to the webhook URL.
41 | func Send(url string, m *Message, maxRetryTime time.Duration) error {
42 | buf, err := json.Marshal(m)
43 | if err != nil {
44 | return err
45 | }
46 |
47 | err = backoff.Retry(
48 | func() error {
49 | r, err := http.Post(url, "application/json", bytes.NewReader(buf))
50 | if err != nil {
51 | return err
52 | }
53 | defer r.Body.Close()
54 |
55 | if r.StatusCode > 399 {
56 | return fmt.Errorf("unexpected response status code: %d", r.StatusCode)
57 | }
58 | return nil
59 | },
60 | backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(maxRetryTime)),
61 | )
62 |
63 | if err != nil {
64 | return err
65 | }
66 | return nil
67 | }
68 |
--------------------------------------------------------------------------------
/slack/slack_test.go:
--------------------------------------------------------------------------------
1 | package slack
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 | "time"
8 | )
9 |
10 | func TestSend(t *testing.T) {
11 | cases := map[string]struct {
12 | message *Message
13 | backoff uint8
14 | wantErr bool
15 | }{
16 | "ok": {
17 | message: &Message{Channel: "concourse"},
18 | backoff: 0,
19 | },
20 | "retry ok": {
21 | message: &Message{Channel: "concourse"},
22 | backoff: 1,
23 | },
24 | "retry fail": {
25 | message: &Message{Channel: "concourse"},
26 | backoff: 255,
27 | wantErr: true,
28 | },
29 | }
30 |
31 | for name, c := range cases {
32 | t.Run(name, func(t *testing.T) {
33 | tries := c.backoff
34 |
35 | s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
36 | if tries > 0 {
37 | tries--
38 | http.Error(w, "", http.StatusUnauthorized)
39 | }
40 | }))
41 | defer s.Close()
42 |
43 | err := Send(s.URL, c.message, 2*time.Second)
44 | if err != nil && !c.wantErr {
45 | t.Fatalf("unexpected error from Send:\n\t(ERR): %s", err)
46 | } else if err == nil && c.wantErr {
47 | t.Fatalf("expected an error from Send:\n\t(GOT): nil")
48 | }
49 | })
50 | }
51 | }
52 |
--------------------------------------------------------------------------------