Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 21 additions & 11 deletions app/cli/cmd/attached_integration_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,32 @@ func attachedIntegrationListTableOutput(attachments []*action.AttachedIntegratio
fmt.Println("Integrations attached to workflows")
t := newTableWriter()
t.AppendHeader(table.Row{"ID", "Kind", "Config", "Attached At", "Workflow"})
for _, i := range attachments {
wf := i.Workflow
integration := i.Integration
for _, attachment := range attachments {
wf := attachment.Workflow
integration := attachment.Integration

// Merge attachment and integration configs to show them in the same table
// If the same key exists in both configs, the value in attachment config will be used
if attachment.Config == nil {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that this code was buggy when the registration didn't have any metadata.

attachment.Config = make(map[string]any)
}

if integration.Config == nil {
integration.Config = make(map[string]any)
}

var options []string
if i.Config != nil {
maps.Copy(i.Config, integration.Config)
for k, v := range i.Config {
if v == "" {
continue
}
options = append(options, fmt.Sprintf("%s: %v", k, v))
maps.Copy(integration.Config, attachment.Config)

// Show it as key-value pairs
for k, v := range integration.Config {
if v == "" {
continue
}
options = append(options, fmt.Sprintf("%s: %v", k, v))
}

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

Expand Down
2 changes: 0 additions & 2 deletions app/cli/cmd/available_integration_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ func availableIntegrationListTableOutput(items []*action.AvailableIntegrationIte
return nil
}

fmt.Println("Available integrations ready to be used during registration")

t := newTableWriter()
t.AppendHeader(table.Row{"ID", "Version", "Description"})
for _, i := range items {
Expand Down
2 changes: 1 addition & 1 deletion app/controlplane/cmd/wire_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions app/controlplane/extensions/core/discord/v1/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Discord Webhook Extension

Send attestations to Discord using webhooks.
## How to use it

1. To get started, you need to register the extension in your Chainloop organization.

```console
$ chainloop integration registered add discord-webhook --opt webhook=[webhookURL]
```
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should mention anything about the way we store webhooks? We treat them like credentials, which may be worth mentioning here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as an user I don't see value on mentioning it, or do you think that it could help with adoption?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok. I would like to know that this is stored securely without looking into the code.


optionally you can specify a custom username

```console
$ chainloop integration registered add discord-webhook --opt webhook=[webhookURL] --opt username=[username]
```

2. Attach the integration to your workflow.

```console
chainloop integration attached add --workflow $WID --integration $IID
```
295 changes: 295 additions & 0 deletions app/controlplane/extensions/core/discord/v1/discord.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
//
// Copyright 2023 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package discord

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"strings"
"text/template"

"github.com/chainloop-dev/chainloop/app/controlplane/extensions/sdk/v1"
"github.com/go-kratos/kratos/v2/log"
)

type Integration struct {
*sdk.FanOutIntegration
}

// 1 - API schema definitions
type registrationRequest struct {
WebhookURL string `json:"webhook" jsonschema:"format=uri,description=URL of the discord webhook"`
Username string `json:"username,omitempty" jsonschema:"minLength=1,description=Override the default username of the webhook "`
}

type attachmentRequest struct{}

// 2 - Configuration state
type registrationState struct {
// Information from the webhook
WebhookName string `json:"name"`
WebhookOwner string `json:"owner"`

// Username to be used while posting the message
Username string `json:"username,omitempty"`
}

func New(l log.Logger) (sdk.FanOut, error) {
base, err := sdk.NewFanOut(
&sdk.NewParams{
ID: "discord-webhook",
Version: "0.1",
Description: "Send attestations to Discord",
Logger: l,
InputSchema: &sdk.InputSchema{
Registration: registrationRequest{},
Attachment: attachmentRequest{},
},
},
sdk.WithEnvelope(),
)

if err != nil {
return nil, err
}

return &Integration{base}, nil
}

type webhookResponse struct {
Name string `json:"name"`
User struct {
Username string `json:"username"`
} `json:"user"`
}

// Register is executed when a operator wants to register a specific instance of this integration with their Chainloop organization
func (i *Integration) Register(_ context.Context, req *sdk.RegistrationRequest) (*sdk.RegistrationResponse, error) {
i.Logger.Info("registration requested")

var request *registrationRequest
if err := sdk.FromConfig(req.Payload, &request); err != nil {
return nil, fmt.Errorf("invalid registration request: %w", err)
}

// Test the webhook URL and extract some information from it to use it as reference for the user
resp, err := http.Get(request.WebhookURL)
if err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}
defer resp.Body.Close()

var webHookInfo webhookResponse
if err := json.NewDecoder(resp.Body).Decode(&webHookInfo); err != nil {
return nil, fmt.Errorf("invalid webhook URL: %w", err)
}

// Configuration State
config, err := sdk.ToConfig(&registrationState{
WebhookName: webHookInfo.Name,
WebhookOwner: webHookInfo.User.Username,
Username: request.Username,
})
if err != nil {
return nil, fmt.Errorf("marshalling configuration: %w", err)
}

return &sdk.RegistrationResponse{
Configuration: config,
// We treat the webhook URL as a sensitive field so we store it in the credentials storage
Credentials: &sdk.Credentials{Password: request.WebhookURL},
}, nil
}

// Attachment is executed when to attach a registered instance of this integration to a specific workflow
func (i *Integration) Attach(_ context.Context, _ *sdk.AttachmentRequest) (*sdk.AttachmentResponse, error) {
i.Logger.Info("attachment requested")
return &sdk.AttachmentResponse{}, nil
}

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

if err := validateExecuteRequest(req); err != nil {
return fmt.Errorf("running validation: %w", err)
}

var config *registrationState
if err := sdk.FromConfig(req.RegistrationInfo.Configuration, &config); err != nil {
return fmt.Errorf("invalid registration config: %w", err)
}

attestationJSON, err := json.MarshalIndent(req.Input.Attestation.Statement, "", " ")
if err != nil {
return fmt.Errorf("error marshaling JSON: %w", err)
}

metadata := req.ChainloopMetadata
tplData := &templateContent{
WorkflowID: metadata.WorkflowID,
WorkflowName: metadata.WorkflowName,
WorkflowRunID: metadata.WorkflowRunID,
WorkflowProject: metadata.WorkflowProject,
RunnerLink: req.Input.Attestation.Predicate.GetRunLink(),
}

webhookURL := req.RegistrationInfo.Credentials.Password
if err := executeWebhook(webhookURL, config.Username, attestationJSON, renderContent(tplData)); err != nil {
return fmt.Errorf("error executing webhook: %w", err)
}

i.Logger.Info("execution finished")
return nil
}

// Send attestation to Discord

// https://discord.com/developers/docs/reference#uploading-files
// --boundary
// Content-Disposition: form-data; name="payload_json"
// Content-Type: application/json
//
// {
// "content": "New attestation!",
// "attachments": [{
// "id": 0,
// "filename": "attestation.json"
// }]
// }
//
// --boundary
// Content-Disposition: form-data; name="files[0]"; filename="statement.json"
// --boundary
func executeWebhook(webhookURL, usernameOverride string, jsonStatement []byte, msgContent string) error {
var b bytes.Buffer
multipartWriter := multipart.NewWriter(&b)

// webhook POST payload JSON
payload := payloadJSON{
Content: msgContent,
Username: usernameOverride,
Attachments: []payloadAttachment{
{
ID: 0,
Filename: "attestation.json",
},
},
}

payloadJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshalling payload: %w", err)
}

payloadWriter, err := multipartWriter.CreateFormField("payload_json")
if err != nil {
return fmt.Errorf("creating payload form field: %w", err)
}

if _, err := payloadWriter.Write(payloadJSON); err != nil {
return fmt.Errorf("writing payload form field: %w", err)
}

// attach attestation JSON
attachmentWriter, err := multipartWriter.CreateFormFile("files[0]", "statement.json")
if err != nil {
return fmt.Errorf("creating attachment form field: %w", err)
}

if _, err := attachmentWriter.Write(jsonStatement); err != nil {
return fmt.Errorf("writing attachment form field: %w", err)
}

// Needed to dump the content of the multipartWriter to the buffer
multipartWriter.Close()

// #nosec G107 - we are using a constant API URL that is not user input at this stage
r, err := http.Post(webhookURL, multipartWriter.FormDataContentType(), &b)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
defer r.Body.Close()

if r.StatusCode != http.StatusOK {
b, _ := io.ReadAll(r.Body)
return fmt.Errorf("non-OK HTTP status while calling the webhook: %d, body: %s", r.StatusCode, string(b))
}

return nil
}

type payloadJSON struct {
Content string `json:"content"`
Username string `json:"username,omitempty"`
Attachments []payloadAttachment `json:"attachments"`
}

type payloadAttachment struct {
ID int `json:"id"`
Filename string `json:"filename"`
}

func validateExecuteRequest(req *sdk.ExecutionRequest) error {
if req == nil || req.Input == nil {
return errors.New("execution input not received")
}

if req.Input.Attestation == nil {
return errors.New("execution input invalid, envelope is nil")
}

if req.RegistrationInfo == nil || req.RegistrationInfo.Configuration == nil {
return errors.New("missing registration configuration")
}

if req.RegistrationInfo.Credentials == nil {
return errors.New("missing credentials")
}

return nil
}

type templateContent struct {
WorkflowID, WorkflowName, WorkflowProject, WorkflowRunID, RunnerLink string
}

func renderContent(metadata *templateContent) string {
t := template.Must(template.New("content").Parse(msgTemplate))

var b bytes.Buffer
if err := t.Execute(&b, metadata); err != nil {
return ""
}

return strings.Trim(b.String(), "\n")
}

const msgTemplate = `
New attestation received!
- Workflow: {{.WorkflowProject}}/{{.WorkflowName}}
- Workflow Run: {{.WorkflowRunID}}
{{- if .RunnerLink }}
- Link to runner: {{.RunnerLink}}
{{end}}
`
Loading