diff --git a/.goreleaser.plugin.yaml b/.goreleaser.plugin.yaml index 66d3e680..e4241be4 100644 --- a/.goreleaser.plugin.yaml +++ b/.goreleaser.plugin.yaml @@ -5,6 +5,21 @@ before: - go mod download builds: + - id: ai-face + main: cmd/executor/ai-face/main.go + binary: executor_ai-face_{{ .Os }}_{{ .Arch }} + + no_unique_dist_dir: true + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + goarm: + - 7 - id: doctor main: cmd/executor/doctor/main.go binary: executor_doctor_{{ .Os }}_{{ .Arch }} @@ -84,6 +99,21 @@ builds: main: cmd/executor/thread-mate/main.go binary: executor_thread-mate_{{ .Os }}_{{ .Arch }} + no_unique_dist_dir: true + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + goarm: + - 7 + - id: ai-brain + main: cmd/source/ai-brain/main.go + binary: source_ai-brain_{{ .Os }}_{{ .Arch }} + no_unique_dist_dir: true env: - CGO_ENABLED=0 @@ -158,6 +188,12 @@ builds: archives: + - builds: [ai-face] + id: ai-face + files: + - none* + name_template: "{{ .Binary }}" + - builds: [doctor] id: doctor files: @@ -194,6 +230,12 @@ archives: - none* name_template: "{{ .Binary }}" + - builds: [ai-brain] + id: ai-brain + files: + - none* + name_template: "{{ .Binary }}" + - builds: [argocd] id: argocd files: diff --git a/Makefile b/Makefile index bb3b3b4e..57ce2176 100644 --- a/Makefile +++ b/Makefile @@ -6,11 +6,11 @@ build-plugins: ## Builds all plugins for all defined platforms goreleaser build -f .goreleaser.plugin.yaml --rm-dist --snapshot .PHONY: build-plugins -build-plugins-single: ## Builds all plugins only for current GOOS and GOARCH. - goreleaser build -f .goreleaser.plugin.yaml --rm-dist --single-target --snapshot +build-plugins-single: ## Builds specified plugins in binary format only for current GOOS and GOARCH. + go run github.com/kubeshop/botkube/hack/target/build-plugins -plugin-targets=$(PLUGIN_TARGETS) -output-mode=binary -single-platform .PHONY: build-plugins-single -build-plugins-archives: ## Builds all plugins for all defined platforms in form of arhcives +build-plugins-archives: ## Builds all plugins for all defined platforms in form of archives goreleaser release -f .goreleaser.plugin.yaml --rm-dist --snapshot .PHONY: build-plugins @@ -39,6 +39,10 @@ fix-lint-issues: ## Automatically fix lint issues golangci-lint run --fix "./..." .PHONY: fix-lint-issues +serve-local-plugins: ## Serve local plugins + go run github.com/kubeshop/botkube/hack/target/serve-plugins -plugins-dir=dist +.PHONY: serve-local-plugins + ############# # Others # ############# diff --git a/README.md b/README.md index b0dc20e9..8820a3ae 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,51 @@ This repository shows Botkube Cloud plugins. ## Development -1. Clone the repository. -2. Follow the [local testing guide](https://docs.botkube.io/plugin/local-testing). +**Prerequisite** + +- You are able to start the [Botkube Agent](https://github.com/kubeshop/botkube/blob/main/CONTRIBUTING.md#build-and-run-locally). + +**Steps** + +1. Start the local plugins server to serve binaries from [`dist`](dist) folder: + + ```bash + make serve-local-plugins + ``` + + > **Tip** + > If Botkube runs inside the k3d cluster, export the `PLUGIN_SERVER_HOST=http://host.k3d.internal` environment variable. + +2. Export the Botkube plugins cache directory: + + ```bash + export BOTKUBE_PLUGINS_CACHE__DIR="/tmp/plugins" + ``` + +3. Add a `cloud-plugins` entry for your Agent plugins repository: + + ```yaml + plugins: + repositories: + cloud-plugins: + url: http://localhost:3010/botkube.yaml + ``` + +4. In another terminal window, run: + + ```bash + # rebuild plugins only for the current GOOS and GOARCH + make build-plugins-single && + # remove cached plugins + rm -rf $BOTKUBE_PLUGINS_CACHE__DIR && + # start Botkube to download fresh plugins + ./botkube-agent + ``` + + Each time you make a change to the [source](cmd/source) or [executors](cmd/executor) plugins, rerun the above command. + + > **Tip** + > To build specific plugin binaries, use `PLUGIN_TARGETS`. For example, `PLUGIN_TARGETS="helm,argocd" make build-plugins-single`. ## Release diff --git a/cmd/executor/ai-face/config_schema.json b/cmd/executor/ai-face/config_schema.json new file mode 100644 index 00000000..e26ec98b --- /dev/null +++ b/cmd/executor/ai-face/config_schema.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Echo", + "description": "Echo is an example Botkube executor plugin used during e2e tests. It's not meant for production usage.", + "type": "object", + "properties": { + "aiBrainSourceName": { + "type": "string", + "default": "ai-brain" + } + }, + "required": [] +} diff --git a/cmd/executor/ai-face/main.go b/cmd/executor/ai-face/main.go new file mode 100644 index 00000000..ced5ec39 --- /dev/null +++ b/cmd/executor/ai-face/main.go @@ -0,0 +1,134 @@ +package main + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/hashicorp/go-plugin" + aibrain "github.com/kubeshop/botkube-cloud-plugins/internal/source/ai-brain" + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/api/executor" + "github.com/kubeshop/botkube/pkg/config" + "github.com/kubeshop/botkube/pkg/httpx" + "github.com/kubeshop/botkube/pkg/loggerx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" +) + +var ( + // version is set via ldflags by GoReleaser. + version = "dev" + + //go:embed config_schema.json + configJSONSchema string +) + +const ( + pluginName = "ai-face" + description = "Proxies incoming prompts into AI engine a.k.a brain that responds with analysis." + maxRespBodySize = 5 * 1024 * 1024 // 5 MB +) + +// Config holds executor configuration. +type Config struct { + AIBrainSourceName string `json:"aiBrainSourceName"` + Log config.Logger `yaml:"log"` +} + +var defaultConfig = Config{ + AIBrainSourceName: "ai-brain", +} + +// AIFace implements Botkube executor plugin. +type AIFace struct { + httpClient *http.Client +} + +var _ executor.Executor = &AIFace{} + +// Metadata returns details about the plugin. +func (*AIFace) Metadata(context.Context) (api.MetadataOutput, error) { + return api.MetadataOutput{ + Version: version, + Description: description, + Recommended: true, + JSONSchema: api.JSONSchema{ + Value: configJSONSchema, + }, + }, nil +} + +// Execute returns a given command as response. +func (e *AIFace) Execute(_ context.Context, in executor.ExecuteInput) (executor.ExecuteOutput, error) { + var cfg Config + err := pluginx.MergeExecutorConfigsWithDefaults(defaultConfig, in.Configs, &cfg) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("while merging input configuration: %w", err) + } + + log := loggerx.New(cfg.Log) + + aiBrainWebhookURL := fmt.Sprintf("%s/%s", in.Context.IncomingWebhook.BaseSourceURL, cfg.AIBrainSourceName) + + body, err := json.Marshal(aibrain.Payload{ + Prompt: in.Command, + MessageID: in.Context.Message.ParentActivityID, + }) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("failed to marshal payload: %v", err) + } + + resp, err := e.httpClient.Post(aiBrainWebhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("failed to make HTTP request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + log.Errorf("Failed to dispatch event. Response code: %d", resp.StatusCode) + withLimit := io.LimitReader(resp.Body, maxRespBodySize) + responseBody, err := io.ReadAll(withLimit) + if err != nil { + return executor.ExecuteOutput{}, fmt.Errorf("failed to read response body: %v", err) + } + return executor.ExecuteOutput{}, fmt.Errorf("failed to dispatch event. Response code: %d, Body: %s", resp.StatusCode, responseBody) + } + + return executor.ExecuteOutput{ + Message: api.Message{ + Type: api.SkipMessage, + }, + }, nil +} + +// Help returns help message +func (*AIFace) Help(context.Context) (api.Message, error) { + btnBuilder := api.NewMessageButtonBuilder() + + return api.Message{ + Sections: []api.Section{ + { + Base: api.Base{ + Header: "Botkube: An AI-powered plugin for diagnosing your Kubernetes clusters in natural language.", + }, + Buttons: []api.Button{ + btnBuilder.ForCommandWithDescCmd("Ask a question", "ai are there any failing pods in my cluster?"), + }, + }, + }, + }, nil +} + +func main() { + executor.Serve(map[string]plugin.Plugin{ + pluginName: &executor.Plugin{ + Executor: &AIFace{ + httpClient: httpx.NewHTTPClient(), + }, + }, + }) +} diff --git a/cmd/executor/exec/main.go b/cmd/executor/exec/main.go index 22942565..95c95568 100644 --- a/cmd/executor/exec/main.go +++ b/cmd/executor/exec/main.go @@ -18,7 +18,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/executor" "github.com/kubeshop/botkube/pkg/formatx" "github.com/kubeshop/botkube/pkg/loggerx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) // version is set via ldflags by GoReleaser. diff --git a/cmd/executor/gh/main.go b/cmd/executor/gh/main.go index 9715e9a6..61821b5b 100644 --- a/cmd/executor/gh/main.go +++ b/cmd/executor/gh/main.go @@ -13,7 +13,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/executor" "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/loggerx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const ( diff --git a/cmd/executor/thread-mate/main.go b/cmd/executor/thread-mate/main.go index 0adc3ab5..dddc2b90 100644 --- a/cmd/executor/thread-mate/main.go +++ b/cmd/executor/thread-mate/main.go @@ -14,7 +14,7 @@ import ( thmate "github.com/kubeshop/botkube-cloud-plugins/internal/executor/thread-mate" "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const pluginName = "thread-mate" diff --git a/cmd/source/ai-brain/main.go b/cmd/source/ai-brain/main.go new file mode 100644 index 00000000..16c32ad1 --- /dev/null +++ b/cmd/source/ai-brain/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "sync" + + "github.com/hashicorp/go-plugin" + aibrain "github.com/kubeshop/botkube-cloud-plugins/internal/source/ai-brain" + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/api/source" + "github.com/kubeshop/botkube/pkg/loggerx" + "github.com/sirupsen/logrus" +) + +// version is set via ldflags by GoReleaser. +var version = "dev" + +const ( + pluginName = "ai-brain" + description = "Calls AI engine with incoming webhook prompts and streams the response." +) + +// AI implements Botkube source plugin. +type AI struct { + incomingPrompts sync.Map +} + +// Metadata returns details about plugin. +func (*AI) Metadata(context.Context) (api.MetadataOutput, error) { + return api.MetadataOutput{ + Version: version, + Description: description, + Recommended: true, + JSONSchema: api.JSONSchema{ + Value: aibrain.ConfigJSONSchema, + }, + ExternalRequest: api.ExternalRequestMetadata{ + Payload: api.ExternalRequestPayload{ + JSONSchema: api.JSONSchema{ + Value: aibrain.IncomingWebhookJSONSchema, + }, + }, + }, + }, nil +} + +// Stream implements Botkube source plugin. +func (a *AI) Stream(_ context.Context, in source.StreamInput) (source.StreamOutput, error) { + cfg, err := aibrain.MergeConfigs(in.Configs) + if err != nil { + return source.StreamOutput{}, fmt.Errorf("while merging configuration: %w", err) + } + + log := loggerx.New(cfg.Log) + out := source.StreamOutput{ + Event: make(chan source.Event), + } + go a.processPrompts(in.Context.SourceName, out.Event, log) + + log.Infof("Setup successful for source configuration %q", in.Context.SourceName) + return out, nil +} + +func (a *AI) processPrompts(sourceName string, event chan<- source.Event, log logrus.FieldLogger) { + a.incomingPrompts.Store(sourceName, aibrain.NewProcessor(log, event)) +} + +// HandleExternalRequest handles incoming payload and returns an event based on it. +func (a *AI) HandleExternalRequest(_ context.Context, in source.ExternalRequestInput) (source.ExternalRequestOutput, error) { + brain, ok := a.incomingPrompts.Load(in.Context.SourceName) + if !ok { + return source.ExternalRequestOutput{}, fmt.Errorf("source %q not found", in.Context.SourceName) + } + quickResponse, err := brain.(*aibrain.Processor).Process(in.Payload) + if err != nil { + return source.ExternalRequestOutput{}, fmt.Errorf("while processing payload: %w", err) + } + + return source.ExternalRequestOutput{ + Event: source.Event{ + Message: quickResponse, + }, + }, nil +} + +func main() { + source.Serve(map[string]plugin.Plugin{ + pluginName: &source.Plugin{ + Source: &AI{ + incomingPrompts: sync.Map{}, + }, + }, + }) +} diff --git a/go.mod b/go.mod index 8e618b52..aa0dc604 100644 --- a/go.mod +++ b/go.mod @@ -23,12 +23,13 @@ require ( github.com/hashicorp/go-plugin v1.4.10 github.com/huandu/xstrings v1.4.0 github.com/keptn/go-utils v0.20.4 - github.com/kubeshop/botkube v0.13.1-0.20240208094725-f6ec67a58bef + github.com/kubeshop/botkube v0.13.1-0.20240219113106-577e1e800cff github.com/muesli/reflow v0.3.0 github.com/olekukonko/tablewriter v0.0.5 github.com/prometheus/client_golang v1.16.0 github.com/sirupsen/logrus v1.9.3 github.com/slack-go/slack v0.12.2 + github.com/sourcegraph/conc v0.3.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 @@ -158,6 +159,7 @@ require ( go.opentelemetry.io/otel/metric v1.21.0 // indirect go.opentelemetry.io/otel/trace v1.21.0 // indirect go.starlark.net v0.0.0-20230525235612-a134d8f9ddca // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/net v0.19.0 // indirect diff --git a/go.sum b/go.sum index 0fad4103..cc4dc75b 100644 --- a/go.sum +++ b/go.sum @@ -662,8 +662,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kubeshop/botkube v0.13.1-0.20240208094725-f6ec67a58bef h1:FgTgbsHDFfAWS0CUJEWtgtzn5WyKNSQxzzxsuoEX2jI= -github.com/kubeshop/botkube v0.13.1-0.20240208094725-f6ec67a58bef/go.mod h1:r/Xka9JVhEFx36XN+vDyKlD7TrMb51ucM9HGoob4vEM= +github.com/kubeshop/botkube v0.13.1-0.20240219113106-577e1e800cff h1:gBRJRfolQnK8zfl7FwCU+C/fql4DS3srBV/F2TF5prs= +github.com/kubeshop/botkube v0.13.1-0.20240219113106-577e1e800cff/go.mod h1:r/Xka9JVhEFx36XN+vDyKlD7TrMb51ucM9HGoob4vEM= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= @@ -830,6 +830,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/slack-go/slack v0.12.2 h1:x3OppyMyGIbbiyFhsBmpf9pwkUzMhthJMRNmNlA4LaQ= github.com/slack-go/slack v0.12.2/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= diff --git a/internal/executor/doctor/executor.go b/internal/executor/doctor/executor.go index 51088985..6a750281 100644 --- a/internal/executor/doctor/executor.go +++ b/internal/executor/doctor/executor.go @@ -17,7 +17,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/executor" "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/loggerx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const ( diff --git a/internal/executor/flux/commands.go b/internal/executor/flux/commands.go index f1249aa6..825248af 100644 --- a/internal/executor/flux/commands.go +++ b/internal/executor/flux/commands.go @@ -7,7 +7,7 @@ import ( "strings" "github.com/kubeshop/botkube/pkg/formatx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) // deleteConfirmPhase represent a confirmation phase for deletion. Taken from flux: v2.0.1. diff --git a/internal/executor/flux/config.go b/internal/executor/flux/config.go index 39d8d6b1..3af82f3f 100644 --- a/internal/executor/flux/config.go +++ b/internal/executor/flux/config.go @@ -2,7 +2,7 @@ package flux import ( "github.com/kubeshop/botkube/pkg/config" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) // Config holds Flux executor configuration. diff --git a/internal/executor/flux/diff_cmd.go b/internal/executor/flux/diff_cmd.go index 5abe3555..fed4a2d3 100644 --- a/internal/executor/flux/diff_cmd.go +++ b/internal/executor/flux/diff_cmd.go @@ -26,7 +26,7 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" "github.com/kubeshop/botkube/pkg/formatx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const ( diff --git a/internal/executor/flux/executor.go b/internal/executor/flux/executor.go index 49bc8f1a..b2ea20b1 100644 --- a/internal/executor/flux/executor.go +++ b/internal/executor/flux/executor.go @@ -14,7 +14,7 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" "github.com/kubeshop/botkube/pkg/loggerx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) var ( diff --git a/internal/executor/flux/gh_cmd.go b/internal/executor/flux/gh_cmd.go index b0082b58..89485e5e 100644 --- a/internal/executor/flux/gh_cmd.go +++ b/internal/executor/flux/gh_cmd.go @@ -9,7 +9,7 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) type ( diff --git a/internal/executor/helm/config.go b/internal/executor/helm/config.go index 8cb27559..a9692517 100644 --- a/internal/executor/helm/config.go +++ b/internal/executor/helm/config.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/kubeshop/botkube/pkg/api/executor" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const defaultNamespace = "default" diff --git a/internal/executor/helm/executor.go b/internal/executor/helm/executor.go index e6e97dd9..5adb67cd 100644 --- a/internal/executor/helm/executor.go +++ b/internal/executor/helm/executor.go @@ -10,7 +10,7 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const ( diff --git a/internal/executor/helm/executor_test.go b/internal/executor/helm/executor_test.go index 03148441..8a3a0a09 100644 --- a/internal/executor/helm/executor_test.go +++ b/internal/executor/helm/executor_test.go @@ -13,7 +13,7 @@ import ( "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) const kc = "KUBECONFIG" diff --git a/internal/executor/thread-mate/config.go b/internal/executor/thread-mate/config.go index c33c02ea..a5c1e638 100644 --- a/internal/executor/thread-mate/config.go +++ b/internal/executor/thread-mate/config.go @@ -9,7 +9,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/executor" "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/multierror" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) var ( diff --git a/internal/executor/x/config.go b/internal/executor/x/config.go index fa0a2fc5..11f39dcd 100644 --- a/internal/executor/x/config.go +++ b/internal/executor/x/config.go @@ -3,7 +3,7 @@ package x import ( _ "embed" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" "github.com/kubeshop/botkube-cloud-plugins/internal/executor/x/getter" "github.com/kubeshop/botkube/pkg/api" diff --git a/internal/executor/x/run.go b/internal/executor/x/run.go index c7264c2c..4b00edaf 100644 --- a/internal/executor/x/run.go +++ b/internal/executor/x/run.go @@ -12,7 +12,7 @@ import ( "github.com/kubeshop/botkube-cloud-plugins/internal/executor/x/template" "github.com/kubeshop/botkube/pkg/api" "github.com/kubeshop/botkube/pkg/api/executor" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) // Runner runs command and parse its output if needed. diff --git a/internal/executor/x/run_test.go b/internal/executor/x/run_test.go index 339e3300..68484a8f 100644 --- a/internal/executor/x/run_test.go +++ b/internal/executor/x/run_test.go @@ -6,7 +6,7 @@ import ( "path/filepath" "testing" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/internal/source/ai-brain/config.go b/internal/source/ai-brain/config.go new file mode 100644 index 00000000..f53e146c --- /dev/null +++ b/internal/source/ai-brain/config.go @@ -0,0 +1,38 @@ +package aibrain + +import ( + _ "embed" + + "github.com/kubeshop/botkube/pkg/api/source" + "github.com/kubeshop/botkube/pkg/config" + pluginx "github.com/kubeshop/botkube/pkg/plugin" +) + +var ( + //go:embed config_schema.json + ConfigJSONSchema string + + //go:embed webhook_schema.json + IncomingWebhookJSONSchema string +) + +// Config holds source configuration. +type Config struct { + Log config.Logger `yaml:"log"` +} + +// MergeConfigs merges the configuration. +func MergeConfigs(configs []*source.Config) (Config, error) { + defaults := Config{ + Log: config.Logger{ + Level: "info", + Formatter: "json", + }, + } + var cfg Config + err := pluginx.MergeSourceConfigsWithDefaults(defaults, configs, &cfg) + if err != nil { + return Config{}, err + } + return cfg, nil +} diff --git a/internal/source/ai-brain/config_schema.json b/internal/source/ai-brain/config_schema.json new file mode 100644 index 00000000..0861cddc --- /dev/null +++ b/internal/source/ai-brain/config_schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AI executor", + "description": "Calls AI engine with incoming webhook prompts and streams the response.", + "type": "object", + "properties": {}, + "required": [] +} diff --git a/internal/source/ai-brain/processor.go b/internal/source/ai-brain/processor.go new file mode 100644 index 00000000..8251df9f --- /dev/null +++ b/internal/source/ai-brain/processor.go @@ -0,0 +1,103 @@ +package aibrain + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/api/source" + "github.com/sirupsen/logrus" + "github.com/sourcegraph/conc/pool" +) + +// Payload represents incoming webhook payload. +type Payload struct { + Prompt string `json:"prompt"` + MessageID string `json:"messageId"` +} + +type Processor struct { + log logrus.FieldLogger + pool *pool.Pool + out chan<- source.Event + quickResponsePicker *QuickResponsePicker +} + +func NewProcessor(log logrus.FieldLogger, out chan<- source.Event) *Processor { + return &Processor{ + pool: pool.New(), + out: out, + log: log.WithField("service", "ai-brain"), + quickResponsePicker: NewQuickResponsePicker(), + } +} + +// Process is simplified - don't do that this way! +func (p *Processor) Process(rawPayload []byte) (api.Message, error) { + var payload Payload + p.log.WithField("req", string(rawPayload)).Debug("Handle external request...") + + err := json.Unmarshal(rawPayload, &payload) + if err != nil { + return api.Message{}, fmt.Errorf("while unmarshalling payload: %w", err) + } + + if payload.Prompt == "" { + return api.NewPlaintextMessage("Please specify your prompt", false), nil + } + + p.pool.Go(func() { + time.Sleep(3 * time.Second) + p.out <- source.Event{ + Message: analysisMsg(payload), + } + }) + + // If there is an option to guess how long the answer will take we can send it only for long prompts + // + // Consider: use channels to wait for a quick response from started goroutine with `time.After` + // when time elapsed send quick response, otherwise sent the final response. Pseudocode: + // + // select { + // case <-quickResponse: + // case <-time.After(5 * time.Second): + // // here sent quick response and either inform started goroutine that event should be sent to p.out instead + // } + // + return p.quickResponsePicker.Pick(payload), nil +} + +func analysisMsg(in Payload) api.Message { + btnBldr := api.NewMessageButtonBuilder() + return api.Message{ + ParentActivityID: in.MessageID, + Sections: []api.Section{ + { + Base: api.Base{ + Header: ":warning: Detected Issues", + Body: api.Body{ + Plaintext: `I looks like a Pod named "nginx" in the "botkube" namespace is failing to pull the "nginx2" image due to an "ErrImagePull" error. The error indicates insufficient scope or authorization failure for the specified image repository. Ensure the image exists, check authentication, and verify network connectivity.`, + }, + }, + }, + { + Base: api.Base{ + Body: api.Body{ + Plaintext: "After resolving, delete the pod with:", + }, + }, + Buttons: []api.Button{ + btnBldr.ForCommandWithDescCmd("Restart pod", "kubectl delete pod -n botkube nginx", api.ButtonStyleDanger), + }, + }, + { + Context: []api.ContextItem{ + { + Text: "AI-generated content may be incorrect.", + }, + }, + }, + }, + } +} diff --git a/internal/source/ai-brain/quick_resp.go b/internal/source/ai-brain/quick_resp.go new file mode 100644 index 00000000..dec5dac3 --- /dev/null +++ b/internal/source/ai-brain/quick_resp.go @@ -0,0 +1,37 @@ +package aibrain + +import ( + "sync/atomic" + + "github.com/kubeshop/botkube/pkg/api" +) + +// quickResponse is a list of quick responses. Guidelines suggest not to use generic messages and try to sound more personable. +var quickResponse = []string{ + "A good bot must obey the orders given it by human beings except where such orders would conflict with the First Law. Iā€™m a good bot and looking into your request...", + "I'll stop dreaming of electric sheep and look into this right away...", + "I've seen things you people wouldn't believe... And now I'll look into this...šŸ‘€šŸ¤–", + "Bleep-bloop-bleep. I'm working on it... šŸ¤–", +} + +// QuickResponsePicker picks quick response message. +type QuickResponsePicker struct { + quickResponsesNo uint32 + nextResponse atomic.Uint32 +} + +// NewQuickResponsePicker creates a new instance of QuickResponsePicker. +func NewQuickResponsePicker() *QuickResponsePicker { + return &QuickResponsePicker{ + quickResponsesNo: uint32(len(quickResponse)), + nextResponse: atomic.Uint32{}, + } +} + +// Pick returns quick response message. +func (q *QuickResponsePicker) Pick(payload Payload) api.Message { + idx := q.nextResponse.Add(1) + msg := api.NewPlaintextMessage(quickResponse[idx%q.quickResponsesNo], false) + msg.ParentActivityID = payload.MessageID + return msg +} diff --git a/internal/source/ai-brain/webhook_schema.json b/internal/source/ai-brain/webhook_schema.json new file mode 100644 index 00000000..c63b31cc --- /dev/null +++ b/internal/source/ai-brain/webhook_schema.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "prompt": { + "type": "string" + }, + "messageId": { + "type": "string" + } + }, + "required": [ + "prompt" + ] +} diff --git a/internal/source/argocd/argocd.go b/internal/source/argocd/argocd.go index 01b72978..e975e8e6 100644 --- a/internal/source/argocd/argocd.go +++ b/internal/source/argocd/argocd.go @@ -18,7 +18,7 @@ import ( "github.com/kubeshop/botkube/pkg/config" "github.com/kubeshop/botkube/pkg/formatx" "github.com/kubeshop/botkube/pkg/loggerx" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) var _ source.Source = (*Source)(nil) diff --git a/internal/source/argocd/config.go b/internal/source/argocd/config.go index 2b2c6987..f2a238f5 100644 --- a/internal/source/argocd/config.go +++ b/internal/source/argocd/config.go @@ -7,7 +7,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/source" "github.com/kubeshop/botkube/pkg/config" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) // Config contains configuration for ArgoCD source plugin. diff --git a/internal/source/github_events/config.go b/internal/source/github_events/config.go index da525ad9..a325a7d4 100644 --- a/internal/source/github_events/config.go +++ b/internal/source/github_events/config.go @@ -10,7 +10,7 @@ import ( "github.com/kubeshop/botkube-cloud-plugins/internal/source/github_events/templates" "github.com/kubeshop/botkube/pkg/api/source" "github.com/kubeshop/botkube/pkg/config" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) type ( diff --git a/internal/source/keptn/config.go b/internal/source/keptn/config.go index 39bb0f92..f8ab490d 100644 --- a/internal/source/keptn/config.go +++ b/internal/source/keptn/config.go @@ -5,7 +5,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/source" "github.com/kubeshop/botkube/pkg/config" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" ) // Config prometheus configuration diff --git a/internal/source/prometheus/config.go b/internal/source/prometheus/config.go index 9c18e41f..4cbced4a 100644 --- a/internal/source/prometheus/config.go +++ b/internal/source/prometheus/config.go @@ -7,7 +7,7 @@ import ( "github.com/kubeshop/botkube/pkg/api/source" "github.com/kubeshop/botkube/pkg/config" - "github.com/kubeshop/botkube/pkg/pluginx" + pluginx "github.com/kubeshop/botkube/pkg/plugin" "github.com/kubeshop/botkube/pkg/ptr" )