Skip to content

Commit 95f814f

Browse files
authored
feat: Added Slack plugin (#217)
Signed-off-by: Daniel Liszka <[email protected]>
1 parent 9f16aff commit 95f814f

File tree

8 files changed

+366
-3
lines changed

8 files changed

+366
-3
lines changed

app/controlplane/plugins/core/discord-webhook/v1/discord.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func (i *Integration) Attach(_ context.Context, _ *sdk.AttachmentRequest) (*sdk.
125125
return &sdk.AttachmentResponse{}, nil
126126
}
127127

128-
// Execute will be instantiate when either an attestation or a material has been received
128+
// Execute will be instantiated when either an attestation or a material has been received
129129
// It's up to the plugin builder to differentiate between inputs
130130
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error {
131131
i.Logger.Info("execution requested")

app/controlplane/plugins/core/guac/v1/guac.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ func (i *Integration) Attach(_ context.Context, _ *sdk.AttachmentRequest) (*sdk.
123123
return &sdk.AttachmentResponse{}, nil
124124
}
125125

126-
// Execute will be instantiate when either an attestation or a material has been received
126+
// Execute will be instantiated when either an attestation or a material has been received
127127
// It's up to the plugin builder to differentiate between inputs
128128
func (i *Integration) Execute(ctx context.Context, req *sdk.ExecutionRequest) error {
129129
// Extract registration and attachment configuration if needed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Slack Webhook Plugin
2+
3+
Send attestations to Slack using webhooks.
4+
5+
## How to use it
6+
7+
1. To get started, you need to register the plugin in your Chainloop organization.
8+
9+
```console
10+
$ chainloop integration registered add slack-webhook --opt webhook=[webhookURL]
11+
```
12+
13+
2. Attach the integration to your workflow.
14+
15+
```console
16+
chainloop integration attached add --workflow $WID --integration $IID
17+
```
18+
19+
## Registration Input Schema
20+
21+
|Field|Type|Required|Description|
22+
|---|---|---|---|
23+
|webhook|string (uri)|yes|URL of the slack webhook|
24+
25+
```json
26+
{
27+
"$schema": "https://json-schema.org/draft/2020-12/schema",
28+
"$id": "https://github.com/chainloop-dev/chainloop/app/controlplane/plugins/core/slack-webhook/v1/registration-request",
29+
"properties": {
30+
"webhook": {
31+
"type": "string",
32+
"format": "uri",
33+
"description": "URL of the slack webhook"
34+
}
35+
},
36+
"additionalProperties": false,
37+
"type": "object",
38+
"required": [
39+
"webhook"
40+
]
41+
}
42+
```
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package slack
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"io"
25+
"net/http"
26+
"strings"
27+
"text/template"
28+
29+
"github.com/chainloop-dev/chainloop/app/controlplane/plugins/sdk/v1"
30+
"github.com/go-kratos/kratos/v2/log"
31+
)
32+
33+
type Integration struct {
34+
*sdk.FanOutIntegration
35+
}
36+
37+
// 1 - API schema definitions
38+
type registrationRequest struct {
39+
WebhookURL string `json:"webhook" jsonschema:"format=uri,description=URL of the slack webhook"`
40+
}
41+
42+
type attachmentRequest struct{}
43+
44+
func New(l log.Logger) (sdk.FanOut, error) {
45+
base, err := sdk.NewFanOut(
46+
&sdk.NewParams{
47+
ID: "slack-webhook",
48+
Version: "1.0",
49+
Description: "Send attestations to Slack",
50+
Logger: l,
51+
InputSchema: &sdk.InputSchema{
52+
Registration: registrationRequest{},
53+
Attachment: attachmentRequest{},
54+
},
55+
},
56+
)
57+
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
return &Integration{base}, nil
63+
}
64+
65+
// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
66+
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
67+
i.Logger.Info("registration requested")
68+
69+
var request *registrationRequest
70+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
71+
return nil, fmt.Errorf("invalid registration request: %w", err)
72+
}
73+
74+
if err := executeWebhook(request.WebhookURL, "This is a test message. Welcome to Chainloop!"); err != nil {
75+
return nil, fmt.Errorf("error validating a webhook: %w", err)
76+
}
77+
78+
return &sdk.RegistrationResponse{
79+
// We treat the webhook URL as a sensitive field so we store it in the credentials storage
80+
Credentials: &sdk.Credentials{Password: request.WebhookURL},
81+
}, nil
82+
}
83+
84+
// Attachment is executed when to attach a registered instance of this integration to a specific workflow
85+
func (i *Integration) Attach(_ context.Context, _ *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
86+
i.Logger.Info("attachment requested")
87+
return &sdk.AttachmentResponse{}, nil
88+
}
89+
90+
// Execute will be instantiated when either an attestation or a material has been received
91+
// It's up to the plugin builder to differentiate between inputs
92+
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error {
93+
i.Logger.Info("execution requested")
94+
95+
if err := validateExecuteRequest(req); err != nil {
96+
return fmt.Errorf("running validation: %w", err)
97+
}
98+
99+
attestationJSON, err := json.MarshalIndent(req.Input.Attestation.Statement, "", " ")
100+
if err != nil {
101+
return fmt.Errorf("error marshaling JSON: %w", err)
102+
}
103+
104+
metadata := req.ChainloopMetadata
105+
// I was not able to make backticks work in the template
106+
a := fmt.Sprintf("\n```\n%s\n```\n", string(attestationJSON))
107+
tplData := &templateContent{
108+
WorkflowID: metadata.WorkflowID,
109+
WorkflowName: metadata.WorkflowName,
110+
WorkflowRunID: metadata.WorkflowRunID,
111+
WorkflowProject: metadata.WorkflowProject,
112+
RunnerLink: req.Input.Attestation.Predicate.GetRunLink(),
113+
Attestation: a,
114+
}
115+
116+
webhookURL := req.RegistrationInfo.Credentials.Password
117+
if err := executeWebhook(webhookURL, renderContent(tplData)); err != nil {
118+
return fmt.Errorf("error executing webhook: %w", err)
119+
}
120+
121+
i.Logger.Info("execution finished")
122+
return nil
123+
}
124+
125+
// Send attestation to Slack
126+
func executeWebhook(webhookURL, msgContent string) error {
127+
payload := map[string]string{
128+
"text": msgContent,
129+
}
130+
jsonPayload, err := json.Marshal(payload)
131+
if err != nil {
132+
return fmt.Errorf("error encoding payload: %w", err)
133+
}
134+
135+
requestBody := bytes.NewReader(jsonPayload)
136+
137+
// #nosec G107 - we are using a constant API URL that is not user input at this stage
138+
r, err := http.Post(webhookURL, "application/json", requestBody)
139+
if err != nil {
140+
return fmt.Errorf("error making request: %w", err)
141+
}
142+
defer r.Body.Close()
143+
144+
if r.StatusCode != http.StatusOK {
145+
b, _ := io.ReadAll(r.Body)
146+
return fmt.Errorf("non-OK HTTP status while calling the webhook: %d, body: %s", r.StatusCode, string(b))
147+
}
148+
149+
return nil
150+
}
151+
152+
func validateExecuteRequest(req *sdk.ExecutionRequest) error {
153+
if req == nil || req.Input == nil {
154+
return errors.New("execution input not received")
155+
}
156+
157+
if req.Input.Attestation == nil {
158+
return errors.New("execution input invalid, envelope is nil")
159+
}
160+
161+
if req.RegistrationInfo == nil {
162+
return errors.New("missing registration configuration")
163+
}
164+
165+
if req.RegistrationInfo.Credentials == nil {
166+
return errors.New("missing credentials")
167+
}
168+
169+
return nil
170+
}
171+
172+
type templateContent struct {
173+
WorkflowID, WorkflowName, WorkflowProject, WorkflowRunID, RunnerLink, Attestation string
174+
}
175+
176+
func renderContent(metadata *templateContent) string {
177+
t := template.Must(template.New("content").Parse(msgTemplate))
178+
179+
var b bytes.Buffer
180+
if err := t.Execute(&b, metadata); err != nil {
181+
return ""
182+
}
183+
184+
return strings.Trim(b.String(), "\n")
185+
}
186+
187+
const msgTemplate = `
188+
New attestation received!
189+
- Workflow: {{.WorkflowProject}}/{{.WorkflowName}}
190+
- Workflow Run: {{.WorkflowRunID}}
191+
{{- if .RunnerLink }}
192+
- Link to runner: {{.RunnerLink}}
193+
{{end}}
194+
{{.Attestation}}
195+
`
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
//
2+
// Copyright 2023 The Chainloop Authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package slack
17+
18+
import (
19+
"encoding/json"
20+
"testing"
21+
22+
"github.com/stretchr/testify/assert"
23+
"github.com/stretchr/testify/require"
24+
)
25+
26+
func TestValidateRegistrationInput(t *testing.T) {
27+
testCases := []struct {
28+
name string
29+
input map[string]interface{}
30+
errMsg string
31+
}{
32+
{
33+
name: "not ok, missing required property",
34+
input: map[string]interface{}{},
35+
errMsg: "missing properties: 'webhook'",
36+
},
37+
{
38+
name: "not ok, random properties",
39+
input: map[string]interface{}{"foo": "bar"},
40+
errMsg: "additionalProperties 'foo' not allowed",
41+
},
42+
{
43+
name: "ok, all properties",
44+
input: map[string]interface{}{"webhook": "http://repo.io"},
45+
},
46+
{
47+
name: "ok, webhook with path",
48+
input: map[string]interface{}{"webhook": "http://repo/foo/bar"},
49+
},
50+
{
51+
name: "not ok, invalid webhook, missing protocol",
52+
input: map[string]interface{}{"webhook": "repo.io"},
53+
errMsg: "is not valid 'uri'",
54+
},
55+
{
56+
name: "not ok, empty webhook",
57+
input: map[string]interface{}{"webhook": ""},
58+
errMsg: "is not valid 'uri'",
59+
},
60+
}
61+
62+
integration, err := New(nil)
63+
require.NoError(t, err)
64+
65+
for _, tc := range testCases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
payload, err := json.Marshal(tc.input)
68+
require.NoError(t, err)
69+
70+
err = integration.ValidateRegistrationRequest(payload)
71+
if tc.errMsg != "" {
72+
assert.ErrorContains(t, err, tc.errMsg)
73+
} else {
74+
assert.NoError(t, err)
75+
}
76+
})
77+
}
78+
}
79+
80+
func TestRenderContent(t *testing.T) {
81+
testCases := []struct {
82+
name string
83+
input *templateContent
84+
expected string
85+
}{
86+
{
87+
name: "all fields",
88+
input: &templateContent{
89+
WorkflowRunID: "deadbeef",
90+
WorkflowName: "test",
91+
WorkflowProject: "project",
92+
RunnerLink: "http://runner.io",
93+
},
94+
expected: `New attestation received!
95+
- Workflow: project/test
96+
- Workflow Run: deadbeef
97+
- Link to runner: http://runner.io`,
98+
},
99+
{
100+
name: "no runner link",
101+
input: &templateContent{
102+
WorkflowRunID: "deadbeef",
103+
WorkflowName: "test",
104+
WorkflowProject: "project",
105+
},
106+
expected: `New attestation received!
107+
- Workflow: project/test
108+
- Workflow Run: deadbeef`,
109+
},
110+
}
111+
112+
for _, tc := range testCases {
113+
t.Run(tc.name, func(t *testing.T) {
114+
actual := renderContent(tc.input)
115+
assert.Equal(t, tc.expected, actual)
116+
})
117+
}
118+
}
119+
120+
func TestNewIntegration(t *testing.T) {
121+
_, err := New(nil)
122+
assert.NoError(t, err)
123+
}

app/controlplane/plugins/core/template/v1/extension.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ func (i *Integration) Attach(_ context.Context, req *sdk.AttachmentRequest) (*sd
147147
return response, nil
148148
}
149149

150-
// Execute will be instantiate when either an attestation or a material has been received
150+
// Execute will be instantiated when either an attestation or a material has been received
151151
// It's up to the plugin builder to differentiate between inputs
152152
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error {
153153
i.Logger.Info("execution requested")

0 commit comments

Comments
 (0)