diff --git a/.goreleaser.plugin.yaml b/.goreleaser.plugin.yaml index 3a6bc8c39..73de459bc 100644 --- a/.goreleaser.plugin.yaml +++ b/.goreleaser.plugin.yaml @@ -33,6 +33,16 @@ builds: goarch: *goarch goarm: *goarm + - id: x + main: cmd/executor/x/main.go + binary: executor_x_{{ .Os }}_{{ .Arch }} + + no_unique_dist_dir: true + env: *env + goos: *goos + goarch: *goarch + goarm: *goarm + - id: gh main: cmd/executor/gh/main.go binary: executor_gh_{{ .Os }}_{{ .Arch }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c5a3dcba..0832f23ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -185,6 +185,9 @@ For faster development, you can also build and run Botkube outside K8s cluster. > **Note** > Each time you make a change to the [source](cmd/source) or [executors](cmd/executor) plugins re-run the above command. + > **Note** + > To build specific plugin binaries, use `PLUGIN_TARGETS`. For example `PLUGIN_TARGETS="x, kubectl" make build-plugins-single`. + ## Making A Change - Before making any significant changes, please [open an issue](https://github.com/kubeshop/botkube/issues). Discussing your proposed changes ahead of time will make the contribution process smooth for everyone. diff --git a/build/Dockerfile b/build/Dockerfile index 8db8b15e9..80ffbf915 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.15 +FROM alpine:3.18 ARG botkube_version="dev" LABEL org.opencontainers.image.source="git@github.com:kubeshop/botkube.git" \ org.opencontainers.image.title="Botkube" \ @@ -9,6 +9,11 @@ LABEL org.opencontainers.image.source="git@github.com:kubeshop/botkube.git" \ COPY botkube /usr/local/bin/botkube +RUN apk add --no-cache 'git=>2.38' 'openssh=~9.3' && \ + mkdir /root/.ssh && \ + chmod 700 /root/.ssh && \ + ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts + # Create Non Privileged user RUN addgroup --gid 1001 botkube && \ adduser -S --uid 1001 --ingroup botkube botkube diff --git a/cmd/executor/gh/main.go b/cmd/executor/gh/main.go index 92cc5b946..13f07d910 100644 --- a/cmd/executor/gh/main.go +++ b/cmd/executor/gh/main.go @@ -162,7 +162,11 @@ func createGitHubIssue(cfg Config, title, mdBody string) (string, error) { "GH_TOKEN": cfg.GitHub.Token, } - return pluginx.ExecuteCommandWithEnvs(context.Background(), cmd, envs) + output, err := pluginx.ExecuteCommand(context.Background(), cmd, pluginx.ExecuteCommandEnvs(envs)) + if err != nil { + return "", err + } + return output.Stdout, nil } // IssueDetails holds all available information about a given issue. @@ -190,8 +194,8 @@ func getIssueDetails(ctx context.Context, namespace, name, kubeConfigPath string return IssueDetails{ Type: name, Namespace: namespace, - Logs: logs, - Version: ver, + Logs: logs.Stdout, + Version: ver.Stdout, }, nil } diff --git a/cmd/executor/x/main.go b/cmd/executor/x/main.go new file mode 100644 index 000000000..e0fd2ae70 --- /dev/null +++ b/cmd/executor/x/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "fmt" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/alexflint/go-arg" + "github.com/hashicorp/go-plugin" + "github.com/sirupsen/logrus" + + "github.com/kubeshop/botkube/internal/executor/x" + "github.com/kubeshop/botkube/internal/executor/x/getter" + "github.com/kubeshop/botkube/internal/executor/x/output" + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/loggerx" + "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" +) + +// version is set via ldflags by GoReleaser. +var version = "dev" + +const pluginName = "x" + +// XExecutor implements Botkube executor plugin. +type XExecutor struct{} + +func (i *XExecutor) Help(_ context.Context) (api.Message, error) { + help := heredoc.Doc(` + Usage: + x run [COMMAND] [FLAGS] Run a specified command with optional flags + x install [SOURCE] Install a binary using the https://github.com/zyedidia/eget syntax. + + Usage Examples: + # Install the Helm CLI + + x install https://get.helm.sh/helm-v3.10.3-linux-amd64.tar.gz --file helm + + # Run the 'helm list -A' command. + + x run helm list -A + + Options: + -h, --help Show this help message`) + return api.NewCodeBlockMessage(help, true), nil +} + +// Metadata returns details about Echo plugin. +func (*XExecutor) Metadata(context.Context) (api.MetadataOutput, error) { + return api.MetadataOutput{ + Version: version, + Description: "Install and run CLIs directly from chat window without hassle. All magic included.", + Dependencies: x.GetPluginDependencies(), + JSONSchema: jsonSchema(), + }, nil +} + +type ( + Commands struct { + Install *InstallCmd `arg:"subcommand:install"` + Run *RunCmd `arg:"subcommand:run"` + } + InstallCmd struct { + Tool []string `arg:"positional"` + } + RunCmd struct { + Tool []string `arg:"positional"` + } +) + +func escapePositionals(in string) string { + for _, name := range []string{"run", "install"} { + if strings.Contains(in, name) { + return strings.Replace(in, name, fmt.Sprintf("%s -- ", name), 1) + } + } + return in +} + +// Execute returns a given command as response. +// +//nolint:gocritic // hugeParam: in is heavy (80 bytes); consider passing it by pointer +func (i *XExecutor) Execute(ctx context.Context, in executor.ExecuteInput) (executor.ExecuteOutput, error) { + var cmd Commands + in.Command = escapePositionals(in.Command) + err := pluginx.ParseCommand(pluginName, in.Command, &cmd) + switch err { + case nil: + case arg.ErrHelp: + msg, _ := i.Help(ctx) + return executor.ExecuteOutput{ + Message: msg, + }, nil + default: + return executor.ExecuteOutput{}, fmt.Errorf("while parsing input command: %w", err) + } + + cfg := x.Config{ + Templates: []getter.Source{ + {Ref: getDefaultTemplateSource()}, + }, + } + if err := pluginx.MergeExecutorConfigs(in.Configs, &cfg); err != nil { + return executor.ExecuteOutput{}, err + } + + log := loggerx.New(cfg.Logger) + + renderer := x.NewRenderer() + err = renderer.RegisterAll(map[string]x.Render{ + "parser:table:.*": output.NewTableCommandParser(log), + }) + if err != nil { + return executor.ExecuteOutput{}, err + } + + switch { + case cmd.Run != nil: + tool := Normalize(strings.Join(cmd.Run.Tool, " ")) + log.WithField("tool", tool).Info("Running command...") + + state := state.ExtractSlackState(in.Context.SlackState) + + kubeConfigPath, deleteFn, err := i.getKubeconfig(ctx, log, in) + defer deleteFn() + if err != nil { + return executor.ExecuteOutput{}, err + } + + return x.NewRunner(log, renderer).Run(ctx, cfg, state, tool, kubeConfigPath) + case cmd.Install != nil: + var ( + tool = Normalize(strings.Join(cmd.Install.Tool, " ")) + dir, isCustom = cfg.TmpDir.Get() + downloadCmd = fmt.Sprintf("eget %s", tool) + ) + + log.WithFields(logrus.Fields{ + "dir": dir, + "isCustom": isCustom, + "downloadCmd": downloadCmd, + }).Info("Installing binary...") + + if _, err := pluginx.ExecuteCommand(ctx, downloadCmd, pluginx.ExecuteCommandEnvs(map[string]string{ + "EGET_BIN": dir, + })); err != nil { + return executor.ExecuteOutput{}, err + } + + return executor.ExecuteOutput{ + Message: api.NewPlaintextMessage("Binary was installed successfully", false), + }, nil + } + return executor.ExecuteOutput{ + Message: api.NewPlaintextMessage("Command not supported", false), + }, nil +} + +func (i *XExecutor) getKubeconfig(ctx context.Context, log logrus.FieldLogger, in executor.ExecuteInput) (string, func(), error) { + if len(in.Context.KubeConfig) == 0 { + return "", func() {}, nil + } + kubeConfigPath, deleteFn, err := pluginx.PersistKubeConfig(ctx, in.Context.KubeConfig) + if err != nil { + return "", func() {}, fmt.Errorf("while writing kubeconfig file: %w", err) + } + + return kubeConfigPath, func() { + err := deleteFn(ctx) + if err != nil { + log.WithError(err).WithField("kubeconfigPath", kubeConfigPath).Error("Failed to delete kubeconfig file") + } + }, nil +} + +func main() { + executor.Serve(map[string]plugin.Plugin{ + pluginName: &executor.Plugin{ + Executor: &XExecutor{}, + }, + }) +} + +// jsonSchema returns JSON schema for the executor. +func jsonSchema() api.JSONSchema { + return api.JSONSchema{ + Value: heredoc.Docf(`{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "x", + "description": "Install and run CLIs directly from the chat window without hassle. All magic included.", + "type": "object", + "properties": { + "templates": { + "type": "array", + "title": "List of templates", + "description": "An array of templates that define how to convert the command output into an interactive message.", + "items": { + "type": "object", + "properties": { + "ref": { + "title": "Link to templates source", + "description": "It uses the go-getter library, which supports multiple URL formats (such as HTTP, Git repositories, or S3) and is able to unpack archives. For more details, see the documentation at https://github.com/hashicorp/go-getter.", + "type": "string", + "default": "" + } + }, + "required": [ + "ref" + ], + "additionalProperties": false + } + } + }, + "required": [ + "templates" + ] + }`, getDefaultTemplateSource()), + } +} + +func getDefaultTemplateSource() string { + ver := version + if ver == "dev" { + ver = "main" + } + return fmt.Sprintf("github.com/kubeshop/botkube//cmd/executor/x/templates?ref=%s", ver) +} + +func Normalize(in string) string { + out := formatx.RemoveHyperlinks(in) + out = strings.NewReplacer(`“`, `"`, `”`, `"`, `‘`, `"`, `’`, `"`).Replace(out) + + out = strings.TrimSpace(out) + + return out +} diff --git a/cmd/executor/x/templates/argo.yaml b/cmd/executor/x/templates/argo.yaml new file mode 100644 index 000000000..7d42befc3 --- /dev/null +++ b/cmd/executor/x/templates/argo.yaml @@ -0,0 +1,12 @@ +templates: + - command: + prefix: "argo list" + parser: "table" + message: + select: + name: "Workflows" + itemKey: "{{ .Namespace }}/{{ .Name }}" + actions: + logs: "argo logs {{ .Name }} -n {{ .Namespace }}" + describe: "argo get {{ .Name }} -n {{ .Namespace }}" + delete: "argo delete {{ .Name }} -n {{ .Namespace }}" diff --git a/cmd/executor/x/templates/flux.yaml b/cmd/executor/x/templates/flux.yaml new file mode 100644 index 000000000..5b1a585e5 --- /dev/null +++ b/cmd/executor/x/templates/flux.yaml @@ -0,0 +1,16 @@ +templates: + - trigger: + command: "flux get sources" + type: "parser:table:space" + message: + selects: + - name: "Source" + keyTpl: "{{ .Name }}" + actions: + export: "flux export source git {{ .Name }}" + preview: | + Name: {{ .Name }} + Revision: {{ .Revision }} + Suspended: {{ .Suspended }} + Ready: {{ .Ready }} + Message: {{ .Message}} diff --git a/cmd/executor/x/templates/helm.yaml b/cmd/executor/x/templates/helm.yaml new file mode 100644 index 000000000..b2ecfae41 --- /dev/null +++ b/cmd/executor/x/templates/helm.yaml @@ -0,0 +1,17 @@ +templates: + - trigger: + command: "helm list" + type: "parser:table:space" + message: + selects: + - name: "Release" + keyTpl: "{{ .Namespace }}/{{ .Name }}" + actions: + notes: "helm get notes {{ .Name }} -n {{ .Namespace }}" + values: "helm get values {{ .Name }} -n {{ .Namespace }}" + delete: "helm delete {{ .Name }} -n {{ .Namespace }}" + preview: | + Name: {{ .Name }} + Namespace: {{ .Namespace }} + Status: {{ .Status }} + Chart: {{ .Chart }} diff --git a/go.mod b/go.mod index 8d818fab1..eb3b9a63f 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/hashicorp/go-plugin v1.4.8 github.com/hashicorp/go-version v1.6.0 github.com/hasura/go-graphql-client v0.8.1 + github.com/huandu/xstrings v1.3.2 github.com/infracloudio/msbotbuilder-go v0.2.5 github.com/knadh/koanf v1.4.4 github.com/mattermost/mattermost-server/v5 v5.39.3 diff --git a/go.sum b/go.sum index 214a66b38..49683b9d2 100644 --- a/go.sum +++ b/go.sum @@ -1127,6 +1127,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4Dvx github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs= github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw= +github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/icrowley/fake v0.0.0-20180203215853-4178557ae428/go.mod h1:uhpZMVGznybq1itEKXj6RYw9I71qK4kH+OGMjRC4KEo= diff --git a/hack/goreleaser.sh b/hack/goreleaser.sh index 0268afcc2..9ffa291f1 100755 --- a/hack/goreleaser.sh +++ b/hack/goreleaser.sh @@ -34,8 +34,7 @@ release_snapshot() { save_images() { prepare - if [ -z "${IMAGE_TAG}" ] - then + if [ -z "${IMAGE_TAG}" ]; then echo "Missing IMAGE_TAG." exit 1 fi @@ -47,15 +46,14 @@ save_images() { # Save images IMAGE_FILE_NAME_PREFIX=$(echo "${IMAGE_REPOSITORY}" | tr "/" "-") - docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 > ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-amd64.tar - docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 > ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-arm64.tar - docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 > ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-armv7.tar + docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 >${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-amd64.tar + docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 >${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-arm64.tar + docker save ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 >${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-armv7.tar } load_and_push_images() { prepare - if [ -z "${IMAGE_TAG}" ] - then + if [ -z "${IMAGE_TAG}" ]; then echo "Missing IMAGE_TAG." exit 1 fi @@ -68,7 +66,7 @@ load_and_push_images() { docker load --input ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-arm64.tar docker load --input ${IMAGE_SAVE_LOAD_DIR}/${IMAGE_FILE_NAME_PREFIX}-armv7.tar - # Push images + # Push images docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-amd64 docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-arm64 docker push ${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${GORELEASER_CURRENT_TAG}-armv7 @@ -92,12 +90,28 @@ build() { goreleaser/goreleaser release --rm-dist --snapshot --skip-publish } +build_plugins_command() { + local command="goreleaser build -f .goreleaser.plugin.yaml --rm-dist --snapshot" + + local targets=() + if [ -n "$PLUGIN_TARGETS" ]; then + IFS=',' read -ra targets <<<"$PLUGIN_TARGETS" + fi + + for target in "${targets[@]}"; do + command+=" --id $target" + done + + echo "$command" +} + build_plugins() { - goreleaser build -f .goreleaser.plugin.yaml --rm-dist --snapshot + eval "$(build_plugins_command)" } build_plugins_single() { - goreleaser build -f .goreleaser.plugin.yaml --rm-dist --snapshot --single-target + command+="$(build_plugins_command) --single-target" + eval "$command" } build_single() { @@ -114,39 +128,39 @@ build_single() { } usage() { - cat < 0 && len(groups[0]) > 1 { + out.PageIndex, _ = strconv.Atoi(groups[0][1]) + } + + out.ToExecute = selectIndicatorFinder.ReplaceAllString(out.ToExecute, "") + out.ToExecute = pageIndicatorFinder.ReplaceAllString(out.ToExecute, "") + out.ToExecute = strings.TrimSpace(out.ToExecute) + + return out +} diff --git a/internal/executor/x/cmd_parse_test.go b/internal/executor/x/cmd_parse_test.go new file mode 100644 index 000000000..fdef13fc4 --- /dev/null +++ b/internal/executor/x/cmd_parse_test.go @@ -0,0 +1,54 @@ +package x + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParse(t *testing.T) { + tests := []struct { + input string + expected Command + }{ + { + input: "x run helm list -A", + expected: Command{ + ToExecute: "x run helm list -A", + IsRawRequired: false, + }, + }, + { + input: "x run helm list -A @raw", + expected: Command{ + ToExecute: "x run helm list -A", + IsRawRequired: true, + }, + }, + { + input: "x run kubectl get pods @idx:123", + expected: Command{ + ToExecute: "x run kubectl get pods", + IsRawRequired: false, + }, + }, + { + input: "x run kubectl get pods @idx:abc", + expected: Command{ + ToExecute: "x run kubectl get pods @idx:abc", + IsRawRequired: false, + PageIndex: 1, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + // when + gotCmd := Parse(tc.input) + + assert.Equal(t, tc.expected.ToExecute, gotCmd.ToExecute) + assert.Equal(t, tc.expected.IsRawRequired, gotCmd.IsRawRequired) + }) + } +} diff --git a/internal/executor/x/config.go b/internal/executor/x/config.go new file mode 100644 index 000000000..9ce0f0258 --- /dev/null +++ b/internal/executor/x/config.go @@ -0,0 +1,31 @@ +package x + +import ( + "github.com/kubeshop/botkube/internal/executor/x/getter" + "github.com/kubeshop/botkube/internal/plugin" + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/config" +) + +// Config holds x plugin configuration. +type Config struct { + Templates []getter.Source `yaml:"templates"` + TmpDir plugin.TmpDir `yaml:"tmpDir"` + Logger config.Logger +} + +// GetPluginDependencies returns x plugin dependencies. +func GetPluginDependencies() map[string]api.Dependency { + return map[string]api.Dependency{ + "eget": { + URLs: map[string]string{ + "windows/amd64": "https://github.com/zyedidia/eget/releases/download/v1.3.3/eget-1.3.3-windows_amd64.zip//eget-1.3.3-windows_amd64", + "darwin/amd64": "https://github.com/zyedidia/eget/releases/download/v1.3.3/eget-1.3.3-darwin_amd64.tar.gz//eget-1.3.3-darwin_amd64", + "darwin/arm64": "https://github.com/zyedidia/eget/releases/download/v1.3.3/eget-1.3.3-darwin_arm64.tar.gz//eget-1.3.3-darwin_arm64", + "linux/amd64": "https://github.com/zyedidia/eget/releases/download/v1.3.3/eget-1.3.3-linux_amd64.tar.gz//eget-1.3.3-linux_amd64", + "linux/arm64": "https://github.com/zyedidia/eget/releases/download/v1.3.3/eget-1.3.3-linux_arm64.tar.gz//eget-1.3.3-linux_arm64", + "linux/386": "https://github.com/zyedidia/eget/releases/download/v1.3.3/eget-1.3.3-linux_386.tar.gz//eget-1.3.3-linux_386", + }, + }, + } +} diff --git a/internal/executor/x/getter/download.go b/internal/executor/x/getter/download.go new file mode 100644 index 000000000..6239658dc --- /dev/null +++ b/internal/executor/x/getter/download.go @@ -0,0 +1,45 @@ +package getter + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "os" + "path/filepath" +) + +var hasher = sha256.New() + +func sha(in string) string { + hasher.Reset() + hasher.Write([]byte(in)) + return base64.URLEncoding.EncodeToString(hasher.Sum(nil)) +} + +// EnsureDownloaded downloads given sources only if not yet downloaded. +// It's a weak comparison based on the source path. +func EnsureDownloaded(ctx context.Context, templateSources []Source, dir string) error { + for _, tpl := range templateSources { + dst := filepath.Join(dir, sha(tpl.Ref)) + err := runIfFileDoesNotExist(dst, func() error { + return Download(ctx, tpl.Ref, dst) + }) + if err != nil { + return err + } + } + + return nil +} + +func runIfFileDoesNotExist(path string, fn func() error) error { + _, err := os.Stat(path) + switch { + case err == nil: + case os.IsNotExist(err): + return fn() + default: + return err + } + return nil +} diff --git a/internal/executor/x/getter/getter.go b/internal/executor/x/getter/getter.go new file mode 100644 index 000000000..409432ccd --- /dev/null +++ b/internal/executor/x/getter/getter.go @@ -0,0 +1,28 @@ +package getter + +import ( + "context" + "fmt" + "os" + + "github.com/hashicorp/go-getter" +) + +// Download downloads data from a given source to local file system under a given destination path. +func Download(ctx context.Context, src, dst string) error { + pwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("while getting current dir: %w", err) + } + + // Build the client + client := &getter.Client{ + Ctx: ctx, + Src: src, + Dst: dst, + Pwd: pwd, + Mode: getter.ClientModeDir, + } + + return client.Get() +} diff --git a/internal/executor/x/getter/load.go b/internal/executor/x/getter/load.go new file mode 100644 index 000000000..eca3c43a1 --- /dev/null +++ b/internal/executor/x/getter/load.go @@ -0,0 +1,57 @@ +package getter + +import ( + "context" + "io/fs" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Source holds information about source location. +type Source struct { + Ref string `yaml:"ref"` +} + +// Load downloads defined sources and read them from the FS. +func Load[T any](ctx context.Context, tmpDir string, templateSources []Source) ([]T, error) { + err := EnsureDownloaded(ctx, templateSources, tmpDir) + if err != nil { + return nil, err + } + + var out []T + err = Walk(tmpDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + if filepath.Ext(d.Name()) != ".yaml" { + return nil + } + + file, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return err + } + + var cfg struct { + Templates []T `yaml:"templates"` + } + err = yaml.Unmarshal(file, &cfg) + if err != nil { + return err + } + out = append(out, cfg.Templates...) + return nil + }) + if err != nil { + return nil, err + } + + return out, nil +} diff --git a/internal/executor/x/getter/walk.go b/internal/executor/x/getter/walk.go new file mode 100644 index 000000000..d223c9842 --- /dev/null +++ b/internal/executor/x/getter/walk.go @@ -0,0 +1,53 @@ +// Package getter. +// +// Code copied from: https://github.com/facebookarchive/symwalk/blob/42004b9f322246749dd73ad71008b1f3160c0052/walk.go#L12-L45 +// BSD License +// +// # For symwalk software +// +// Copyright (c) 2015, Facebook, Inc. All rights reserved. +package getter + +import ( + "io/fs" + "os" + "path/filepath" +) + +// symwalkFunc calls the provided WalkFn for regular files. +// However, when it encounters a symbolic link, it resolves the link fully using the +// filepath.EvalSymlinks function and recursively calls symwalk.Walk on the resolved path. +// This ensures that unlink filepath.Walk, traversal does not stop at symbolic links. +// +// Note that symwalk.Walk does not terminate if there are any non-terminating loops in +// the file structure. +func walk(filename string, linkDirname string, walkFn fs.WalkDirFunc) error { + return filepath.WalkDir(filename, func(path string, d fs.DirEntry, err error) error { + if fname, err := filepath.Rel(filename, path); err == nil { + path = filepath.Join(linkDirname, fname) + } else { + return err + } + + if err == nil && d.Type()&os.ModeSymlink == os.ModeSymlink { + finalPath, err := filepath.EvalSymlinks(path) + if err != nil { + return err + } + info, err := os.Lstat(finalPath) + if err != nil { + return walkFn(path, d, err) + } + if info.IsDir() { + return walk(finalPath, path, walkFn) + } + } + + return walkFn(path, d, err) + }) +} + +// Walk extends filepath.Walk to also follow symlinks +func Walk(path string, walkFn fs.WalkDirFunc) error { + return walk(path, path, walkFn) +} diff --git a/internal/executor/x/mathx/int.go b/internal/executor/x/mathx/int.go new file mode 100644 index 000000000..1170912bc --- /dev/null +++ b/internal/executor/x/mathx/int.go @@ -0,0 +1,19 @@ +package mathx + +// IncreaseWithMax increase in by 1 but only up to max value. +func IncreaseWithMax(in, max int) int { + in++ + if in > max { + return max + } + return in +} + +// DecreaseWithMin decreases in by 1 but only to min value. +func DecreaseWithMin(in, min int) int { + in-- + if in < min { + return min + } + return in +} diff --git a/internal/executor/x/output/helpers.go b/internal/executor/x/output/helpers.go new file mode 100644 index 000000000..7b927f341 --- /dev/null +++ b/internal/executor/x/output/helpers.go @@ -0,0 +1,15 @@ +package output + +import "github.com/kubeshop/botkube/pkg/api" + +func noItemsMsg() api.Message { + return api.Message{ + Sections: []api.Section{ + { + Base: api.Base{ + Description: "Not found.", + }, + }, + }, + } +} diff --git a/internal/executor/x/output/message_parser.go b/internal/executor/x/output/message_parser.go new file mode 100644 index 000000000..b9f17ec4f --- /dev/null +++ b/internal/executor/x/output/message_parser.go @@ -0,0 +1,271 @@ +package output + +import ( + "fmt" + "strconv" + "strings" + gotemplate "text/template" + + "github.com/huandu/xstrings" + "github.com/sirupsen/logrus" + + "github.com/kubeshop/botkube/internal/executor/x" + "github.com/kubeshop/botkube/internal/executor/x/parser" + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/executor/x/template" + "github.com/kubeshop/botkube/pkg/api" +) + +// Parser defines +type Parser interface { + TableSeparated(in string) parser.TableOutput +} + +// TableCommandParser allows to render table command output into interactive message based on registered templates. +type TableCommandParser struct { + parsers map[string]Parser + log logrus.FieldLogger +} + +// NewTableCommandParser returns a new TableCommandParser instance. +func NewTableCommandParser(log logrus.FieldLogger) *TableCommandParser { + return &TableCommandParser{ + log: log, + parsers: map[string]Parser{ + "space": &parser.TableSpace{}, + }, + } +} + +// RenderMessage renders the output string based on a given template. +func (p *TableCommandParser) RenderMessage(cmd, output string, state *state.Container, msgCtx *template.Template) (api.Message, error) { + msg := msgCtx.ParseMessage + parserType := strings.TrimPrefix(msgCtx.Type, "parser:table:") + parser, found := p.parsers[parserType] + if !found { + note := fmt.Sprintf("parser %s is not supported", parserType) + return api.NewPlaintextMessage(note, false), nil + } + + out := parser.TableSeparated(output) + if len(out.Lines) == 0 || len(out.Table.Rows) == 0 { + return noItemsMsg(), nil + } + + var sections []api.Section + + // dropdowns + dropdowns, selectedIdx := p.renderDropdowns(msg.Selects, out.Table, cmd, state) + sections = append(sections, dropdowns) + // preview + preview, err := p.renderPreview(msg, out, selectedIdx) + if err != nil { + return api.Message{}, err + } + sections = append(sections, preview) // todo check header + 1 line at least + + // actions + actions, err := p.renderActions(msg, out.Table, cmd, selectedIdx) + if err != nil { + return api.Message{}, err + } + sections = append(sections, actions) + + return api.Message{ + ReplaceOriginal: state != nil && state.SelectsBlockID != "", // dropdown clicked, let's do the update + OnlyVisibleForYou: true, + Sections: sections, + }, nil +} + +func (p *TableCommandParser) renderActions(msgCtx template.ParseMessage, table parser.Table, cmd string, idx int) (api.Section, error) { + if idx >= len(table.Rows) { + idx = len(table.Rows) - 1 + } + btnBuilder := api.NewMessageButtonBuilder() + var actions []api.OptionItem + for name, tpl := range msgCtx.Actions { // based on the selected item + out, err := p.renderGoTemplate(tpl, table.Headers, table.Rows[idx]) + if err != nil { + return api.Section{}, err + } + actions = append(actions, api.OptionItem{ + Name: name, + Value: out, + }) + } + if len(actions) == 0 { + return api.Section{}, nil + } + + return api.Section{ + Buttons: []api.Button{ + btnBuilder.ForCommand("Raw output", fmt.Sprintf("x run %s %s", cmd, x.RawOutputIndicator)), + }, + Selects: api.Selects{ + Items: []api.Select{ + { + Type: api.StaticSelect, + Name: "Actions", + Command: fmt.Sprintf("%s x run", api.MessageBotNamePlaceholder), + OptionGroups: []api.OptionGroup{ + { + Name: "Actions", + Options: actions, + }, + }, + }, + }, + }, + }, nil +} + +func (p *TableCommandParser) renderPreview(msgCtx template.ParseMessage, out parser.TableOutput, requestedRow int) (api.Section, error) { + headerLine := out.Lines[0] + + if requestedRow >= len(out.Table.Rows) { + requestedRow = len(out.Table.Rows) - 1 + } + + renderLine := p.getPreviewLine(out.Lines, requestedRow) + + preview := fmt.Sprintf("%s\n%s", headerLine, renderLine) // just print the first entry + + if msgCtx.Preview != "" { + prev, err := p.renderGoTemplate(msgCtx.Preview, out.Table.Headers, out.Table.Rows[requestedRow]) + if err != nil { + return api.Section{}, err + } + preview = prev + } + + return api.Section{ + Base: api.Base{ + Body: api.Body{ + CodeBlock: preview, + }, + }, + }, nil +} + +func (*TableCommandParser) getPreviewLine(lines []string, idx int) string { + if len(lines) < 2 { // exclude the first line for the header + return "" + } + + requested := idx + 1 + if len(lines) >= requested { + return lines[requested] + } + + return lines[1] // otherwise default first line +} + +func (p *TableCommandParser) renderDropdowns(selects []template.Select, commandData parser.Table, cmd string, state *state.Container) (api.Section, int) { + var ( + dropdowns []api.Select + lastSelectedIdx int + ) + for _, item := range selects { + var ( + name = item.Name + keyTpl = item.KeyTpl + ) + dropdown, selectedIdx := p.selectDropdown(name, cmd, keyTpl, commandData, state) + + if dropdown != nil { + dropdowns = append(dropdowns, *dropdown) + lastSelectedIdx = selectedIdx + } + } + + return api.Section{ + Selects: api.Selects{ + ID: state.GetSelectsBlockID(), + Items: dropdowns, + }, + }, lastSelectedIdx +} + +func (p *TableCommandParser) selectDropdown(name, cmd, keyTpl string, table parser.Table, state *state.Container) (*api.Select, int) { + log := p.log.WithField("selectName", name) + var options []api.OptionItem + for idx, row := range table.Rows { + selectItemName, err := p.renderGoTemplate(keyTpl, table.Headers, row) + if err != nil { + return nil, 0 + } + if selectItemName == "" { + log.Info("key name is empty for dropdown") + continue + } + options = append(options, api.OptionItem{ + Name: selectItemName, + Value: fmt.Sprintf("%s%d", x.SelectIndexIndicator, idx), + }) + } + + if len(options) == 0 { + return nil, 0 + } + + dropdownID := fmt.Sprintf("x run %s", cmd) + idx := p.resolveSelectIdx(state, dropdownID) + if idx >= len(options) { + idx = len(options) - 1 + } + + log.WithFields(logrus.Fields{ + "itemsNo": len(options), + "selectedItem": idx, + }).Info("Dropdown rendered") + return &api.Select{ + Type: api.StaticSelect, + Name: name, + Command: fmt.Sprintf("%s %s", api.MessageBotNamePlaceholder, dropdownID), // storing select ID under command, so we can easily locate it from a given state + InitialOption: &options[idx], + OptionGroups: []api.OptionGroup{ + { + Name: name, + Options: options, + }, + }, + }, idx +} + +func (*TableCommandParser) resolveSelectIdx(state *state.Container, selectID string) int { + item := state.GetField(selectID) + if item == "" { + return 0 + } + + _, item, _ = strings.Cut(item, x.SelectIndexIndicator) + val, _ := strconv.Atoi(item) + return val +} + +func (p *TableCommandParser) renderGoTemplate(tpl string, cols, rows []string) (string, error) { + data := map[string]string{} + for idx, col := range cols { + col = xstrings.ToCamelCase(strings.ToLower(col)) + data[col] = rows[idx] + } + + p.log.WithFields(logrus.Fields{ + "tpl": tpl, + "data": data, + }).Debug("Rendering Go template") + + tmpl, err := gotemplate.New("tpl").Parse(tpl) + if err != nil { + return "", err + } + + var buff strings.Builder + err = tmpl.Execute(&buff, data) + if err != nil { + return "", err + } + + return buff.String(), nil +} diff --git a/internal/executor/x/parser/space_table.go b/internal/executor/x/parser/space_table.go new file mode 100644 index 000000000..a61ec1d3a --- /dev/null +++ b/internal/executor/x/parser/space_table.go @@ -0,0 +1,105 @@ +package parser + +import ( + "bufio" + "strings" + "unicode" + + "github.com/kubeshop/botkube/internal/executor/x/mathx" +) + +// Table holds table data. +type Table struct { + Headers []string + Rows [][]string +} + +// TableOutput returns table output. +type TableOutput struct { + Table Table + Lines []string +} + +// TableSpace destructs table sparated by spaces. +type TableSpace struct{} + +// TableSeparated takes a string input and returns a slice of slices containing the separated values in each row +// and a slice of the original input lines. +// TODO: change the output to a JSON or YAML format to allow standardized parser interface. +func (*TableSpace) TableSeparated(in string) TableOutput { + var out TableOutput + in = replaceTabsWithSpaces(in) + in = strings.TrimSpace(in) + scanner := bufio.NewScanner(strings.NewReader(in)) + + // Parse the headers + var separators []int + if scanner.Scan() { + line := scanner.Text() + separators = getSeparators(line) + out.Lines = append(out.Lines, line) + out.Table.Headers = splitIntoCells(line, separators) + } + // Parse the rows + for scanner.Scan() { + line := scanner.Text() + out.Lines = append(out.Lines, line) + + row := splitIntoCells(line, separators) + out.Table.Rows = append(out.Table.Rows, row) + } + return out +} + +func replaceTabsWithSpaces(in string) string { + return strings.ReplaceAll(in, "\t", " ") +} + +// function takes a line and returns a list of separators (positions of left edges of the cells) +func getSeparators(line string) []int { + var separators []int + for idx, ch := range line { + isCurrentCharSpace := unicode.IsSpace(ch) + if !isCurrentCharSpace { // not separator + continue + } + + var ( + previousIdx = mathx.DecreaseWithMin(idx, 0) + nextIdx = mathx.IncreaseWithMax(idx, len(line)-1) + + isNextSpace = unicode.IsSpace(rune(line[nextIdx])) + wasPrevSpace = unicode.IsSpace(rune(line[previousIdx])) + ) + + if isCurrentCharSpace && isNextSpace { + continue + } + + if isCurrentCharSpace && !wasPrevSpace && !isNextSpace { // check for multi world colum name like "APP VERSION" + continue + } + separators = append(separators, idx) + } + return separators +} + +// function takes a line and a list of separators and returns a list of cells (the line divided by the separators) +func splitIntoCells(line string, separators []int) []string { + var ( + res []string + start = 0 + ) + + separators = append(separators, len(line)) // to add the final "end", otherwise the last 'cell' won't be extracted + for _, end := range separators { + if end > len(line) { + end = len(line) + } + cell := strings.TrimSpace(line[start:end]) + start = end + res = append(res, cell) + } + + return res +} diff --git a/internal/executor/x/parser/space_table_test.go b/internal/executor/x/parser/space_table_test.go new file mode 100644 index 000000000..b421a3d9d --- /dev/null +++ b/internal/executor/x/parser/space_table_test.go @@ -0,0 +1,42 @@ +package parser + +import ( + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/stretchr/testify/assert" +) + +func TestTableSpaceSeparated(t *testing.T) { + // given + input := heredoc.Doc(` + NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION + psql default 1 2023-04-27 19:30:48.042056 +0200 CEST deployed postgresql-12.2.7 15.2.0 + traefik kube-system 1 2023-04-19 20:58:57.709052559 +0000 UTC deployed traefik-10.19.300 2.6.2 + traefik-crd kube-system 1 2023-04-19 20:58:56.564578223 +0000 UTC deployed traefik-crd-10.19.300`) + + expectedTable := Table{ + Headers: []string{"NAME", "NAMESPACE", "REVISION", "UPDATED", "STATUS", "CHART", "APP VERSION"}, + Rows: [][]string{ + {"psql", "default", "1", "2023-04-27 19:30:48.042056 +0200 CEST", "deployed", "postgresql-12.2.7", "15.2.0"}, + {"traefik", "kube-system", "1", "2023-04-19 20:58:57.709052559 +0000 UTC", "deployed", "traefik-10.19.300", "2.6.2"}, + {"traefik-crd", "kube-system", "1", "2023-04-19 20:58:56.564578223 +0000 UTC", "deployed", "traefik-crd-10.19.300", ""}, + }, + } + + expectedLines := []string{ + replaceTabsWithSpaces("NAME NAMESPACE REVISION UPDATED STATUS CHART APP VERSION"), + replaceTabsWithSpaces("psql default 1 2023-04-27 19:30:48.042056 +0200 CEST deployed postgresql-12.2.7 15.2.0 "), + replaceTabsWithSpaces("traefik kube-system 1 2023-04-19 20:58:57.709052559 +0000 UTC deployed traefik-10.19.300 2.6.2 "), + replaceTabsWithSpaces("traefik-crd kube-system 1 2023-04-19 20:58:56.564578223 +0000 UTC deployed traefik-crd-10.19.300"), + } + + parserTable := &TableSpace{} + + // when + actual := parserTable.TableSeparated(input) + + // then + assert.Equal(t, expectedTable, actual.Table) + assert.Equal(t, expectedLines, actual.Lines) +} diff --git a/internal/executor/x/renderer.go b/internal/executor/x/renderer.go new file mode 100644 index 000000000..f520afafc --- /dev/null +++ b/internal/executor/x/renderer.go @@ -0,0 +1,88 @@ +package x + +import ( + "fmt" + "regexp" + "sort" + "strings" + "sync" + + "golang.org/x/exp/maps" + + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/executor/x/template" + "github.com/kubeshop/botkube/pkg/api" +) + +// Render is an interface that knows how to render a given command output. +type Render interface { + // RenderMessage receives command output and a template and produce a final message. + RenderMessage(cmd, output string, state *state.Container, msgCtx *template.Template) (api.Message, error) +} + +// Renderer provides functionality to render command output in requested format. +type Renderer struct { + mux sync.RWMutex + renderer map[string]Render +} + +// NewRenderer returns a new Renderer instance. +func NewRenderer() *Renderer { + return &Renderer{ + renderer: map[string]Render{}, + } +} + +func (r *Renderer) Register(name string, render Render) error { + r.mux.Lock() + defer r.mux.Unlock() + + _, found := r.renderer[name] + if found { + return fmt.Errorf("conflicts: %q was already registered", name) + } + r.renderer[name] = render + return nil +} + +func (r *Renderer) RegisterAll(in map[string]Render) error { + for name, fn := range in { + err := r.Register(name, fn) + if err != nil { + return err + } + } + return nil +} + +// Get return renderer for a given output +func (r *Renderer) Get(output string) (Render, error) { + r.mux.RLock() + defer r.mux.RUnlock() + + printer, found := r.renderer[output] + if found { + return printer, nil + } + keys := maps.Keys(r.renderer) + for _, key := range keys { + matched, err := regexp.MatchString(key, output) + if err != nil { + continue + } + if matched { + return r.renderer[key], nil + } + } + return nil, fmt.Errorf("formatter %q is not available, allowed formatters %q", output, r.availablePrinters()) +} + +func (r *Renderer) availablePrinters() string { + out := make([]string, 0, len(r.renderer)) + for key := range r.renderer { + out = append(out, key) + } + + sort.Strings(out) + return strings.Join(out, " | ") +} diff --git a/internal/executor/x/run.go b/internal/executor/x/run.go new file mode 100644 index 000000000..96193e861 --- /dev/null +++ b/internal/executor/x/run.go @@ -0,0 +1,110 @@ +package x + +import ( + "context" + "strings" + + "github.com/gookit/color" + "github.com/sirupsen/logrus" + + "github.com/kubeshop/botkube/internal/executor/x/getter" + "github.com/kubeshop/botkube/internal/executor/x/state" + "github.com/kubeshop/botkube/internal/executor/x/template" + "github.com/kubeshop/botkube/internal/plugin" + "github.com/kubeshop/botkube/pkg/api" + "github.com/kubeshop/botkube/pkg/api/executor" + "github.com/kubeshop/botkube/pkg/pluginx" +) + +// Runner runs command and parse its output if needed. +type Runner struct { + renderer *Renderer + log logrus.FieldLogger +} + +// NewRunner returns a new Runner instance. +func NewRunner(log logrus.FieldLogger, renderer *Renderer) *Runner { + return &Runner{ + log: log, + renderer: renderer, + } +} + +// Run runs a given command and parse its output if needed. +func (i *Runner) Run(ctx context.Context, cfg Config, state *state.Container, tool string, kubeconfigPath string) (executor.ExecuteOutput, error) { + cmd := Parse(tool) + + templates, err := getter.Load[template.Template](ctx, cfg.TmpDir.GetDirectory(), cfg.Templates) + if err != nil { + return executor.ExecuteOutput{}, err + } + + for _, tpl := range templates { + i.log.WithFields(logrus.Fields{ + "trigger": tpl.Trigger.Command, + "type": tpl.Type, + }).Info("Command template") + } + + out, err := runCmd(ctx, cfg.TmpDir, cmd.ToExecute, map[string]string{ + "KUBECONFIG": kubeconfigPath, + }) + if err != nil { + i.log.WithError(err).WithField("command", cmd.ToExecute).Error("failed to run command") + return executor.ExecuteOutput{}, err + } + + if cmd.IsRawRequired { + i.log.Info("Raw output was explicitly requested") + return executor.ExecuteOutput{ + Message: api.NewCodeBlockMessage(out, true), + }, nil + } + + cmdTemplate, found := template.FindWithPrefix(templates, cmd.ToExecute) + if !found { + i.log.Info("Templates config not found for command") + return executor.ExecuteOutput{ + Message: api.NewCodeBlockMessage(color.ClearCode(out), true), + }, nil + } + + render, err := i.renderer.Get(cmdTemplate.Type) + if err != nil { + return executor.ExecuteOutput{}, err + } + + message, err := render.RenderMessage(cmd.ToExecute, out, state, &cmdTemplate) + if err != nil { + return executor.ExecuteOutput{}, err + } + return executor.ExecuteOutput{ + Message: message, + }, nil +} + +func runCmd(ctx context.Context, tmp plugin.TmpDir, in string, envs map[string]string) (string, error) { + opts := []pluginx.ExecuteCommandMutation{ + pluginx.ExecuteCommandEnvs(envs), + } + + path, custom := tmp.Get() + if custom { + // we installed all assets in different directory, e.g. because we run it locally, + // so we override the default deps path + opts = append(opts, pluginx.ExecuteCommandDependencyDir(path)) + } + + out, err := pluginx.ExecuteCommand(ctx, in, opts...) + if err != nil { + return "", err + } + + var str strings.Builder + str.WriteString(color.ClearCode(out.Stdout)) + if out.Stderr != "" { + str.WriteString("\n") + str.WriteString(color.ClearCode(out.Stderr)) + } + return strings.TrimSpace(str.String()), nil +} diff --git a/internal/executor/x/state/slack.go b/internal/executor/x/state/slack.go new file mode 100644 index 000000000..c53b13267 --- /dev/null +++ b/internal/executor/x/state/slack.go @@ -0,0 +1,54 @@ +package state + +import ( + "github.com/slack-go/slack" +) + +// Container holds message state. +type Container struct { + SelectsBlockID string + Fields map[string]string +} + +// GetSelectsBlockID returns select block ID. +func (c *Container) GetSelectsBlockID() string { + if c == nil { + return "" + } + return c.SelectsBlockID +} + +// GetField returns value for a given field. +func (c *Container) GetField(name string) string { + if c == nil { + return "" + } + return c.Fields[name] +} + +// ExtractSlackState extracts slack state into generic container data. +func ExtractSlackState(state *slack.BlockActionStates) *Container { + if state == nil { + return nil + } + + cnt := Container{ + Fields: map[string]string{}, + } + for blockID, blocks := range state.Values { + cnt.SelectsBlockID = blockID + for id, act := range blocks { + var val string + switch { + case act.SelectedOption.Value != "": + val = act.SelectedOption.Value + case act.Value != "": + val = act.Value + default: + continue + } + cnt.Fields[id] = val + } + } + return &cnt +} diff --git a/internal/executor/x/template/config.go b/internal/executor/x/template/config.go new file mode 100644 index 000000000..13e8e1ff1 --- /dev/null +++ b/internal/executor/x/template/config.go @@ -0,0 +1,77 @@ +package template + +import ( + "strings" + + "gopkg.in/yaml.v3" +) + +type ( + // Template represents a template for message parsing. + Template struct { + Type string `yaml:"type"` + Trigger Trigger `yaml:"trigger"` + ParseMessage ParseMessage `yaml:"-"` + } + + // Trigger represents the trigger configuration for a template. + Trigger struct { + Command string `yaml:"command"` + } + + // ParseMessage holds template for message that will be parsed by defined parser. + ParseMessage struct { + Selects []Select `yaml:"selects"` + Actions map[string]string `yaml:"actions"` + Preview string `yaml:"preview"` + } + // Select holds template select primitive definition. + Select struct { + Name string `yaml:"name"` + KeyTpl string `yaml:"keyTpl"` + } +) + +// UnmarshalYAML is a custom unmarshaler for Template allowing to unmarshal into a proper struct +// base on defined template type. +func (su *Template) UnmarshalYAML(node *yaml.Node) error { + var data struct { + Type string `yaml:"type"` + Trigger Trigger `yaml:"trigger"` + } + err := node.Decode(&data) + if err != nil { + return err + } + + switch { + case strings.HasPrefix(data.Type, "parser:"): + var data struct { + Message ParseMessage `yaml:"message"` + } + err = node.Decode(&data) + if err != nil { + return err + } + su.ParseMessage = data.Message + } + + su.Type = data.Type + su.Trigger = data.Trigger + return nil +} + +// FindWithPrefix finds a template with a matching command prefix. +func FindWithPrefix(tpls []Template, cmd string) (Template, bool) { + for idx := range tpls { + item := tpls[idx] + if item.Trigger.Command == "" { + continue + } + if strings.HasPrefix(cmd, item.Trigger.Command) { + return item, true + } + } + + return Template{}, false +} diff --git a/internal/plugin/tmp_dir.go b/internal/plugin/tmp_dir.go new file mode 100644 index 000000000..69ff6e166 --- /dev/null +++ b/internal/plugin/tmp_dir.go @@ -0,0 +1,26 @@ +package plugin + +import ( + "os" + "path" +) + +type TmpDir string + +func (t TmpDir) Get() (string, bool) { + if t != "" { + return string(t), true + } + + depDir := os.Getenv(DependencyDirEnvName) + if depDir != "" { + return depDir, false + } + + return path.Join(os.TempDir(), "bin"), true +} + +func (t TmpDir) GetDirectory() string { + dir, _ := t.Get() + return dir +} diff --git a/pkg/pluginx/command.go b/pkg/pluginx/command.go index 34fad6edb..7cf538e1a 100644 --- a/pkg/pluginx/command.go +++ b/pkg/pluginx/command.go @@ -46,15 +46,34 @@ func ParseCommand(pluginName, command string, destination any) error { return nil } -// ExecuteCommand is a simple wrapper around exec.CommandContext to simplify running a given -// command. -func ExecuteCommand(ctx context.Context, rawCmd string) (string, error) { - return ExecuteCommandWithEnvs(ctx, rawCmd, nil) +// ExecuteCommandOutput holds ExecuteCommand output. +type ExecuteCommandOutput struct { + Stdout string + Stderr string + ExitCode int } // ExecuteCommandWithEnvs is a simple wrapper around exec.CommandContext to simplify running a given // command. +// +// Deprecated: Use ExecuteCommand(ctx, rawCmd, ExecuteCommandEnvs(envs)) instead. func ExecuteCommandWithEnvs(ctx context.Context, rawCmd string, envs map[string]string) (string, error) { + out, err := ExecuteCommand(ctx, rawCmd, ExecuteCommandEnvs(envs)) + if err != nil { + return "", err + } + return out.Stdout, nil +} + +// ExecuteCommand is a simple wrapper around exec.CommandContext to simplify running a given command. +func ExecuteCommand(ctx context.Context, rawCmd string, mutators ...ExecuteCommandMutation) (ExecuteCommandOutput, error) { + opts := ExecuteCommandOptions{ + DependencyDir: os.Getenv(plugin.DependencyDirEnvName), + } + for _, mutate := range mutators { + mutate(&opts) + } + var stdout, stderr bytes.Buffer parser := shellwords.NewParser() @@ -62,18 +81,17 @@ func ExecuteCommandWithEnvs(ctx context.Context, rawCmd string, envs map[string] parser.ParseBacktick = false args, err := parser.Parse(rawCmd) if err != nil { - return "", err + return ExecuteCommandOutput{}, err } if len(args) < 1 { - return "", fmt.Errorf("invalid raw command: %q", rawCmd) + return ExecuteCommandOutput{}, fmt.Errorf("invalid raw command: %q", rawCmd) } bin, binArgs := args[0], args[1:] - depDir, found := os.LookupEnv(plugin.DependencyDirEnvName) - if found { - // Use exactly the binary from the $PLUGIN_DEPENDENCY_DIR directory - bin = fmt.Sprintf("%s/%s", depDir, bin) + if opts.DependencyDir != "" { + // Use exactly the binary from the dependency directory + bin = fmt.Sprintf("%s/%s", opts.DependencyDir, bin) } //nolint:gosec // G204: Subprocess launched with a potential tainted input or cmd arguments @@ -83,19 +101,23 @@ func ExecuteCommandWithEnvs(ctx context.Context, rawCmd string, envs map[string] cmd.Env = append(cmd.Env, os.Environ()...) - for key, value := range envs { + for key, value := range opts.Envs { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", key, value)) } - if err = cmd.Run(); err != nil { - return "", runErr(stdout.String(), stderr.String(), err) + err = cmd.Run() + out := ExecuteCommandOutput{ + Stdout: stdout.String(), + Stderr: stderr.String(), + ExitCode: cmd.ProcessState.ExitCode(), } - - exitCode := cmd.ProcessState.ExitCode() - if exitCode != 0 { - return "", fmt.Errorf("got non-zero exit code, stdout [%q], stderr [%q]", stdout.String(), stderr.String()) + if err != nil { + return out, runErr(stdout.String(), stderr.String(), err) + } + if out.ExitCode != 0 { + return out, fmt.Errorf("got non-zero exit code, stdout [%q], stderr [%q]", stdout.String(), stderr.String()) } - return stdout.String(), nil + return out, nil } func runErr(sout, serr string, err error) error { diff --git a/pkg/pluginx/command_opts.go b/pkg/pluginx/command_opts.go new file mode 100644 index 000000000..d5fef3a85 --- /dev/null +++ b/pkg/pluginx/command_opts.go @@ -0,0 +1,24 @@ +package pluginx + +// ExecuteCommandOptions represents the options for executing a command. +type ExecuteCommandOptions struct { + Envs map[string]string + DependencyDir string +} + +// ExecuteCommandMutation is a function type that can be used to modify ExecuteCommandOptions. +type ExecuteCommandMutation func(*ExecuteCommandOptions) + +// ExecuteCommandEnvs is a function that sets the environment variables. +func ExecuteCommandEnvs(envs map[string]string) ExecuteCommandMutation { + return func(options *ExecuteCommandOptions) { + options.Envs = envs + } +} + +// ExecuteCommandDependencyDir is a function that sets the dependency directory. +func ExecuteCommandDependencyDir(dir string) ExecuteCommandMutation { + return func(options *ExecuteCommandOptions) { + options.DependencyDir = dir + } +} diff --git a/pkg/pluginx/example_test.go b/pkg/pluginx/example_test.go index 17e87830f..07b5c3db8 100644 --- a/pkg/pluginx/example_test.go +++ b/pkg/pluginx/example_test.go @@ -48,21 +48,21 @@ func Example_executeCommand() { panic(err) } - fmt.Println(out) + fmt.Println(out.Stdout) // output: // hakuna matata } func Example_executeCommandWithEnv() { - out, err := ExecuteCommandWithEnvs(context.Background(), `sh -c "echo ${CUSTOM_ENV}"`, map[string]string{ + out, err := ExecuteCommand(context.Background(), `sh -c "echo ${CUSTOM_ENV}"`, ExecuteCommandEnvs(map[string]string{ "CUSTOM_ENV": "magic-value", - }) + })) if err != nil { panic(err) } - fmt.Println(out) + fmt.Println(out.Stdout) // output: // magic-value