Skip to content

Commit 59caa39

Browse files
authored
feat(extensions): add Discord extension and additional metadata (#177)
Signed-off-by: Miguel Martinez Trivino <miguel@chainloop.dev>
1 parent b2309f2 commit 59caa39

File tree

12 files changed

+526
-32
lines changed

12 files changed

+526
-32
lines changed

app/cli/cmd/attached_integration_list.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,32 @@ func attachedIntegrationListTableOutput(attachments []*action.AttachedIntegratio
5353
fmt.Println("Integrations attached to workflows")
5454
t := newTableWriter()
5555
t.AppendHeader(table.Row{"ID", "Kind", "Config", "Attached At", "Workflow"})
56-
for _, i := range attachments {
57-
wf := i.Workflow
58-
integration := i.Integration
56+
for _, attachment := range attachments {
57+
wf := attachment.Workflow
58+
integration := attachment.Integration
59+
60+
// Merge attachment and integration configs to show them in the same table
61+
// If the same key exists in both configs, the value in attachment config will be used
62+
if attachment.Config == nil {
63+
attachment.Config = make(map[string]any)
64+
}
65+
66+
if integration.Config == nil {
67+
integration.Config = make(map[string]any)
68+
}
5969

6070
var options []string
61-
if i.Config != nil {
62-
maps.Copy(i.Config, integration.Config)
63-
for k, v := range i.Config {
64-
if v == "" {
65-
continue
66-
}
67-
options = append(options, fmt.Sprintf("%s: %v", k, v))
71+
maps.Copy(integration.Config, attachment.Config)
72+
73+
// Show it as key-value pairs
74+
for k, v := range integration.Config {
75+
if v == "" {
76+
continue
6877
}
78+
options = append(options, fmt.Sprintf("%s: %v", k, v))
6979
}
7080

71-
t.AppendRow(table.Row{i.ID, integration.Kind, strings.Join(options, "\n"), i.CreatedAt.Format(time.RFC822), wf.NamespacedName()})
81+
t.AppendRow(table.Row{attachment.ID, integration.Kind, strings.Join(options, "\n"), attachment.CreatedAt.Format(time.RFC822), wf.NamespacedName()})
7282
t.AppendSeparator()
7383
}
7484

app/cli/cmd/available_integration_list.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@ func availableIntegrationListTableOutput(items []*action.AvailableIntegrationIte
4848
return nil
4949
}
5050

51-
fmt.Println("Available integrations ready to be used during registration")
52-
5351
t := newTableWriter()
5452
t.AppendHeader(table.Row{"ID", "Version", "Material Requirement", "Description"})
5553
for _, i := range items {

app/controlplane/cmd/wire_gen.go

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Discord Webhook Extension
2+
3+
Send attestations to Discord using webhooks.
4+
## How to use it
5+
6+
1. To get started, you need to register the extension in your Chainloop organization.
7+
8+
```console
9+
$ chainloop integration registered add discord-webhook --opt webhook=[webhookURL]
10+
```
11+
12+
optionally you can specify a custom username
13+
14+
```console
15+
$ chainloop integration registered add discord-webhook --opt webhook=[webhookURL] --opt username=[username]
16+
```
17+
18+
2. Attach the integration to your workflow.
19+
20+
```console
21+
chainloop integration attached add --workflow $WID --integration $IID
22+
```
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
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 discord
17+
18+
import (
19+
"bytes"
20+
"context"
21+
"encoding/json"
22+
"errors"
23+
"fmt"
24+
"io"
25+
"mime/multipart"
26+
"net/http"
27+
"strings"
28+
"text/template"
29+
30+
"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1"
31+
"github.com/go-kratos/kratos/v2/log"
32+
)
33+
34+
type Integration struct {
35+
*sdk.FanOutIntegration
36+
}
37+
38+
// 1 - API schema definitions
39+
type registrationRequest struct {
40+
WebhookURL string `json:"webhook" jsonschema:"format=uri,description=URL of the discord webhook"`
41+
Username string `json:"username,omitempty" jsonschema:"minLength=1,description=Override the default username of the webhook "`
42+
}
43+
44+
type attachmentRequest struct{}
45+
46+
// 2 - Configuration state
47+
type registrationState struct {
48+
// Information from the webhook
49+
WebhookName string `json:"name"`
50+
WebhookOwner string `json:"owner"`
51+
52+
// Username to be used while posting the message
53+
Username string `json:"username,omitempty"`
54+
}
55+
56+
func New(l log.Logger) (sdk.FanOut, error) {
57+
base, err := sdk.NewFanOut(
58+
&sdk.NewParams{
59+
ID: "discord-webhook",
60+
Version: "0.1",
61+
Description: "Send attestations to Discord",
62+
Logger: l,
63+
InputSchema: &sdk.InputSchema{
64+
Registration: registrationRequest{},
65+
Attachment: attachmentRequest{},
66+
},
67+
},
68+
sdk.WithEnvelope(),
69+
)
70+
71+
if err != nil {
72+
return nil, err
73+
}
74+
75+
return &Integration{base}, nil
76+
}
77+
78+
type webhookResponse struct {
79+
Name string `json:"name"`
80+
User struct {
81+
Username string `json:"username"`
82+
} `json:"user"`
83+
}
84+
85+
// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
86+
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
87+
i.Logger.Info("registration requested")
88+
89+
var request *registrationRequest
90+
if err := sdk.FromConfig(req.Payload, &request); err != nil {
91+
return nil, fmt.Errorf("invalid registration request: %w", err)
92+
}
93+
94+
// Test the webhook URL and extract some information from it to use it as reference for the user
95+
resp, err := http.Get(request.WebhookURL)
96+
if err != nil {
97+
return nil, fmt.Errorf("invalid webhook URL: %w", err)
98+
}
99+
defer resp.Body.Close()
100+
101+
var webHookInfo webhookResponse
102+
if err := json.NewDecoder(resp.Body).Decode(&webHookInfo); err != nil {
103+
return nil, fmt.Errorf("invalid webhook URL: %w", err)
104+
}
105+
106+
// Configuration State
107+
config, err := sdk.ToConfig(&registrationState{
108+
WebhookName: webHookInfo.Name,
109+
WebhookOwner: webHookInfo.User.Username,
110+
Username: request.Username,
111+
})
112+
if err != nil {
113+
return nil, fmt.Errorf("marshalling configuration: %w", err)
114+
}
115+
116+
return &sdk.RegistrationResponse{
117+
Configuration: config,
118+
// We treat the webhook URL as a sensitive field so we store it in the credentials storage
119+
Credentials: &sdk.Credentials{Password: request.WebhookURL},
120+
}, nil
121+
}
122+
123+
// Attachment is executed when to attach a registered instance of this integration to a specific workflow
124+
func (i *Integration) Attach(_ context.Context, _ *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
125+
i.Logger.Info("attachment requested")
126+
return &sdk.AttachmentResponse{}, nil
127+
}
128+
129+
// Execute will be instantiate when either an attestation or a material has been received
130+
// It's up to the extension builder to differentiate between inputs
131+
func (i *Integration) Execute(_ context.Context, req *sdk.ExecutionRequest) error {
132+
i.Logger.Info("execution requested")
133+
134+
if err := validateExecuteRequest(req); err != nil {
135+
return fmt.Errorf("running validation: %w", err)
136+
}
137+
138+
var config *registrationState
139+
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &config); err != nil {
140+
return fmt.Errorf("invalid registration config: %w", err)
141+
}
142+
143+
attestationJSON, err := json.MarshalIndent(req.Input.Attestation.Statement, "", " ")
144+
if err != nil {
145+
return fmt.Errorf("error marshaling JSON: %w", err)
146+
}
147+
148+
metadata := req.ChainloopMetadata
149+
tplData := &templateContent{
150+
WorkflowID: metadata.WorkflowID,
151+
WorkflowName: metadata.WorkflowName,
152+
WorkflowRunID: metadata.WorkflowRunID,
153+
WorkflowProject: metadata.WorkflowProject,
154+
RunnerLink: req.Input.Attestation.Predicate.GetRunLink(),
155+
}
156+
157+
webhookURL := req.RegistrationInfo.Credentials.Password
158+
if err := executeWebhook(webhookURL, config.Username, attestationJSON, renderContent(tplData)); err != nil {
159+
return fmt.Errorf("error executing webhook: %w", err)
160+
}
161+
162+
i.Logger.Info("execution finished")
163+
return nil
164+
}
165+
166+
// Send attestation to Discord
167+
168+
// https://discord.com/developers/docs/reference#uploading-files
169+
// --boundary
170+
// Content-Disposition: form-data; name="payload_json"
171+
// Content-Type: application/json
172+
//
173+
// {
174+
// "content": "New attestation!",
175+
// "attachments": [{
176+
// "id": 0,
177+
// "filename": "attestation.json"
178+
// }]
179+
// }
180+
//
181+
// --boundary
182+
// Content-Disposition: form-data; name="files[0]"; filename="statement.json"
183+
// --boundary
184+
func executeWebhook(webhookURL, usernameOverride string, jsonStatement []byte, msgContent string) error {
185+
var b bytes.Buffer
186+
multipartWriter := multipart.NewWriter(&b)
187+
188+
// webhook POST payload JSON
189+
payload := payloadJSON{
190+
Content: msgContent,
191+
Username: usernameOverride,
192+
Attachments: []payloadAttachment{
193+
{
194+
ID: 0,
195+
Filename: "attestation.json",
196+
},
197+
},
198+
}
199+
200+
payloadJSON, err := json.Marshal(payload)
201+
if err != nil {
202+
return fmt.Errorf("marshalling payload: %w", err)
203+
}
204+
205+
payloadWriter, err := multipartWriter.CreateFormField("payload_json")
206+
if err != nil {
207+
return fmt.Errorf("creating payload form field: %w", err)
208+
}
209+
210+
if _, err := payloadWriter.Write(payloadJSON); err != nil {
211+
return fmt.Errorf("writing payload form field: %w", err)
212+
}
213+
214+
// attach attestation JSON
215+
attachmentWriter, err := multipartWriter.CreateFormFile("files[0]", "statement.json")
216+
if err != nil {
217+
return fmt.Errorf("creating attachment form field: %w", err)
218+
}
219+
220+
if _, err := attachmentWriter.Write(jsonStatement); err != nil {
221+
return fmt.Errorf("writing attachment form field: %w", err)
222+
}
223+
224+
// Needed to dump the content of the multipartWriter to the buffer
225+
multipartWriter.Close()
226+
227+
// #nosec G107 - we are using a constant API URL that is not user input at this stage
228+
r, err := http.Post(webhookURL, multipartWriter.FormDataContentType(), &b)
229+
if err != nil {
230+
return fmt.Errorf("creating request: %w", err)
231+
}
232+
defer r.Body.Close()
233+
234+
if r.StatusCode != http.StatusOK {
235+
b, _ := io.ReadAll(r.Body)
236+
return fmt.Errorf("non-OK HTTP status while calling the webhook: %d, body: %s", r.StatusCode, string(b))
237+
}
238+
239+
return nil
240+
}
241+
242+
type payloadJSON struct {
243+
Content string `json:"content"`
244+
Username string `json:"username,omitempty"`
245+
Attachments []payloadAttachment `json:"attachments"`
246+
}
247+
248+
type payloadAttachment struct {
249+
ID int `json:"id"`
250+
Filename string `json:"filename"`
251+
}
252+
253+
func validateExecuteRequest(req *sdk.ExecutionRequest) error {
254+
if req == nil || req.Input == nil {
255+
return errors.New("execution input not received")
256+
}
257+
258+
if req.Input.Attestation == nil {
259+
return errors.New("execution input invalid, envelope is nil")
260+
}
261+
262+
if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil {
263+
return errors.New("missing registration configuration")
264+
}
265+
266+
if req.RegistrationInfo.Credentials == nil {
267+
return errors.New("missing credentials")
268+
}
269+
270+
return nil
271+
}
272+
273+
type templateContent struct {
274+
WorkflowID, WorkflowName, WorkflowProject, WorkflowRunID, RunnerLink string
275+
}
276+
277+
func renderContent(metadata *templateContent) string {
278+
t := template.Must(template.New("content").Parse(msgTemplate))
279+
280+
var b bytes.Buffer
281+
if err := t.Execute(&b, metadata); err != nil {
282+
return ""
283+
}
284+
285+
return strings.Trim(b.String(), "\n")
286+
}
287+
288+
const msgTemplate = `
289+
New attestation received!
290+
- Workflow: {{.WorkflowProject}}/{{.WorkflowName}}
291+
- Workflow Run: {{.WorkflowRunID}}
292+
{{- if .RunnerLink }}
293+
- Link to runner: {{.RunnerLink}}
294+
{{end}}
295+
`

0 commit comments

Comments
 (0)