├── .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 | [![Go Report Card](https://goreportcard.com/badge/github.com/arbourd/concourse-slack-alert-resource)](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 | --------------------------------------------------------------------------------