From d9f95e5d5e847f5c6e899b77c6cea24160a3a2f6 Mon Sep 17 00:00:00 2001 From: pdefreitas Date: Sun, 13 Apr 2025 03:50:53 +0100 Subject: [PATCH] feat: add CloudEvents Sink Signed-off-by: pdefreitas --- README.md | 9 +- api/v1alpha1/k8sgpt_types.go | 2 +- chart/operator/templates/k8sgpt-crd.yaml | 1 + config/crd/bases/core.k8sgpt.ai_k8sgpts.yaml | 1 + config/samples/exhaustive_sample.yaml | 2 +- go.mod | 8 +- go.sum | 14 +-- pkg/sinks/cloudevents.go | 89 ++++++++++++++++++++ pkg/sinks/sinkreporter.go | 2 + pkg/sinks/sinks_test.go | 74 ++++++++++++++++ 10 files changed, 186 insertions(+), 16 deletions(-) create mode 100644 pkg/sinks/cloudevents.go diff --git a/README.md b/README.md index 17b0bebf..6d135dac 100644 --- a/README.md +++ b/README.md @@ -523,10 +523,11 @@ spec: Optional parameters available for sink. ('type', 'webhook' are required parameters.) -| tool | channel | icon_url | username | -| ---------- | ------- | -------- | -------- | -| Slack | | | | -| Mattermost | ✔️ | ✔️ | ✔️ | +| tool | channel | icon_url | username | +| ----------- | ------- | -------- | -------- | +| Slack | | | | +| Mattermost | ✔️ | ✔️ | ✔️ | +| CloudEvents | | | | diff --git a/api/v1alpha1/k8sgpt_types.go b/api/v1alpha1/k8sgpt_types.go index f0045d12..1eb5fbf7 100644 --- a/api/v1alpha1/k8sgpt_types.go +++ b/api/v1alpha1/k8sgpt_types.go @@ -81,7 +81,7 @@ type GCSBackend struct { } type WebhookRef struct { - // +kubebuilder:validation:Enum=slack;mattermost + // +kubebuilder:validation:Enum=slack;mattermost;cloudevents Type string `json:"type,omitempty"` Endpoint string `json:"webhook,omitempty"` Channel string `json:"channel,omitempty"` diff --git a/chart/operator/templates/k8sgpt-crd.yaml b/chart/operator/templates/k8sgpt-crd.yaml index ebefeae6..141637ee 100644 --- a/chart/operator/templates/k8sgpt-crd.yaml +++ b/chart/operator/templates/k8sgpt-crd.yaml @@ -301,6 +301,7 @@ spec: enum: - slack - mattermost + - cloudevents type: string username: type: string diff --git a/config/crd/bases/core.k8sgpt.ai_k8sgpts.yaml b/config/crd/bases/core.k8sgpt.ai_k8sgpts.yaml index adf8946a..9573f30d 100644 --- a/config/crd/bases/core.k8sgpt.ai_k8sgpts.yaml +++ b/config/crd/bases/core.k8sgpt.ai_k8sgpts.yaml @@ -299,6 +299,7 @@ spec: enum: - slack - mattermost + - cloudevents type: string username: type: string diff --git a/config/samples/exhaustive_sample.yaml b/config/samples/exhaustive_sample.yaml index d54ca3ac..757c0ff9 100644 --- a/config/samples/exhaustive_sample.yaml +++ b/config/samples/exhaustive_sample.yaml @@ -28,7 +28,7 @@ spec: enabled: serviceAccountIRSA: sink: # Webhook for notifications (optional) - type: # (e.g., slack, mattermost) + type: # (e.g., slack, mattermost, cloudevents) endpoint: channel: username: diff --git a/go.mod b/go.mod index 7b6e9388..9e16c7a6 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,14 @@ require ( buf.build/gen/go/k8sgpt-ai/k8sgpt/grpc/go v1.5.1-20240920204244-7a91c8620515.1 buf.build/gen/go/k8sgpt-ai/k8sgpt/protocolbuffers/go v1.35.2-20241118152629-1379a5a1889d.1 github.com/agnivade/levenshtein v1.2.0 + github.com/cloudevents/sdk-go/v2 v2.16.0 github.com/go-logr/logr v1.4.2 + github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.22.1 github.com/onsi/gomega v1.36.2 github.com/prometheus/client_golang v1.20.5 google.golang.org/grpc v1.69.0 gopkg.in/yaml.v2 v2.4.0 - gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.29.3 k8s.io/apimachinery v0.29.3 k8s.io/cli-runtime v0.29.3 @@ -57,7 +58,6 @@ require ( github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7 // indirect github.com/imdario/mergo v0.3.15 // indirect @@ -86,9 +86,8 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect - go.uber.org/atomic v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.24.0 // indirect + go.uber.org/zap v1.27.0 // indirect golang.org/x/net v0.33.0 // indirect golang.org/x/oauth2 v0.23.0 // indirect golang.org/x/sync v0.10.0 // indirect @@ -101,6 +100,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect google.golang.org/protobuf v1.36.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.27.2 // indirect k8s.io/component-base v0.29.3 // indirect k8s.io/klog/v2 v2.110.1 // indirect diff --git a/go.sum b/go.sum index 5879af74..1278985d 100644 --- a/go.sum +++ b/go.sum @@ -14,7 +14,6 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= @@ -27,6 +26,8 @@ github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWR github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudevents/sdk-go/v2 v2.16.0 h1:wnunjgiLQCfYlyo+E4+mFlZtAh7pKn7vT8MMD3lSwCg= +github.com/cloudevents/sdk-go/v2 v2.16.0/go.mod h1:5YWqklyhDSmGzBK/JENKKXdulbPq0JFf3c/KEnMLqgg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= @@ -195,6 +196,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -213,16 +216,15 @@ go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06F go.starlark.net v0.0.0-20230525235612-a134d8f9ddca h1:VdD38733bfYv5tUZwEIskMM93VanwNIi5bIKnDrJdEY= go.starlark.net v0.0.0-20230525235612-a134d8f9ddca/go.mod h1:jxU+3+j+71eXOW14274+SmmuW82qJzl6iZSeqEtTGds= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= -go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= -go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= -go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/pkg/sinks/cloudevents.go b/pkg/sinks/cloudevents.go new file mode 100644 index 00000000..e1bf9f1d --- /dev/null +++ b/pkg/sinks/cloudevents.go @@ -0,0 +1,89 @@ +package sinks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/google/uuid" + "github.com/k8sgpt-ai/k8sgpt-operator/api/v1alpha1" +) + +var _ ISink = (*CloudEventsSink)(nil) + +type CloudEventsSink struct { + Endpoint string + K8sGPT string + Client Client +} + +type CloudEventsData struct { + Text string `json:"text"` + Attachments []CloudEventsAttachment `json:"attachments"` +} + +type CloudEventsAttachment struct { + Type string `json:"type"` + Text string `json:"text"` + Color string `json:"color"` + Title string `json:"title"` +} + +func buildCloudEventsStructuredContent(kind, name, details, k8sgptCR string) cloudevents.Event { + event := cloudevents.NewEvent() + event.SetSource("https://github.com/k8sgpt-ai/k8sgpt-operator") + event.SetType("com.github.k8sgpt-ai.k8sgpt-operator.sinks.cloudevents") + event.SetTime(time.Now().UTC()) + event.SetID(uuid.NewString()) + event.SetData(cloudevents.ApplicationJSON, CloudEventsData{ + Text: fmt.Sprintf(">*[%s] K8sGPT analysis of the %s %s*", k8sgptCR, kind, name), + Attachments: []CloudEventsAttachment{ + { + Type: "mrkdwn", + Text: details, + Color: "danger", + Title: "Report", + }, + }, + }) + return event +} + +func (s *CloudEventsSink) Configure(config v1alpha1.K8sGPT, c Client, sinkSecretValue string) { + s.Endpoint = sinkSecretValue + // check if the webhook url is passed as a sinkSecretValue, if not use spec.sink.webhook + if s.Endpoint == "" { + s.Endpoint = config.Spec.Sink.Endpoint + } + s.Client = c + // take the name of the K8sGPT Custom ResourceRef + s.K8sGPT = config.Name +} + +func (s *CloudEventsSink) Emit(results v1alpha1.ResultSpec) error { + event := buildCloudEventsStructuredContent(results.Kind, results.Name, results.Details, s.K8sGPT) + payload, err := json.Marshal(event) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, s.Endpoint, bytes.NewBuffer(payload)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/cloudevents+json") + resp, err := s.Client.hclient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to send report: %s", resp.Status) + } + + return nil +} diff --git a/pkg/sinks/sinkreporter.go b/pkg/sinks/sinkreporter.go index 6f7413e4..6da757e6 100644 --- a/pkg/sinks/sinkreporter.go +++ b/pkg/sinks/sinkreporter.go @@ -19,6 +19,8 @@ func NewSink(sinkType string) ISink { //Introduce more Sink Providers case "mattermost": return &MattermostSink{} + case "cloudevents": + return &CloudEventsSink{} default: return &SlackSink{} } diff --git a/pkg/sinks/sinks_test.go b/pkg/sinks/sinks_test.go index 34ec2f52..44f3465d 100644 --- a/pkg/sinks/sinks_test.go +++ b/pkg/sinks/sinks_test.go @@ -26,6 +26,11 @@ func Test_NewSink(t *testing.T) { sinkType: "mattermost", want: &MattermostSink{}, }, + { + name: "cloudevents sink", + sinkType: "cloudevents", + want: &CloudEventsSink{}, + }, { name: "default sink", sinkType: "unknown", @@ -190,3 +195,72 @@ func Test_SlackSinkEmit(t *testing.T) { }) } } + +func Test_CloudEventsSinkConfigure(t *testing.T) { + sink := &CloudEventsSink{} + client := NewClient(2 * time.Second) + config := v1alpha1.K8sGPT{ + Spec: v1alpha1.K8sGPTSpec{ + Sink: &v1alpha1.WebhookRef{ + Endpoint: "http://example.com", + }, + }, + } + + sink.Configure(config, *client, "") + + assert.Equal(t, "http://example.com", sink.Endpoint) + assert.Equal(t, client, &sink.Client) +} + +func Test_CloudEventsSinkEmit(t *testing.T) { + tests := []struct { + name string + results v1alpha1.ResultSpec + responseCode int + expectError bool + }{ + { + name: "Successful response", + results: v1alpha1.ResultSpec{ + Kind: "kind", + Name: "name", + }, + responseCode: http.StatusOK, + expectError: false, + }, + { + name: "Failed response", + results: v1alpha1.ResultSpec{ + Kind: "kind", + Name: "name", + }, + responseCode: http.StatusInternalServerError, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sink := &CloudEventsSink{ + Endpoint: "", + Client: *NewClient(2 * time.Second), + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.responseCode) + + })) + defer server.Close() + + sink.Endpoint = server.URL + + err := sink.Emit(tt.results) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +}